Feature: task origin/business logic visibility for managers & admins
- Task cards show an info (ⓘ) icon in the Level row for privileged users Tooltip: e.g. '90 days before renewal · Group level · Template: Request Loss Runs' - Edit modal shows a read-only Task Origin callout at top for privileged users - Added template include to all task queries (tasks page, client page, client tasks API) - Also applied the open-task cutoff fix to /api/clients/[id]/tasks/route.ts (same bug as Luke's)
This commit is contained in:
parent
a4dfa0db59
commit
970ffe2864
7 changed files with 117 additions and 12 deletions
|
|
@ -97,6 +97,9 @@ export default async function ClientDetailPage({
|
|||
policyGroup: {
|
||||
select: { id: true, name: true, renewalDate: true },
|
||||
},
|
||||
template: {
|
||||
select: { id: true, name: true, level: true },
|
||||
},
|
||||
assignments: {
|
||||
include: {
|
||||
user: {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { UserSelectContent } from '@/components/ui/user-select-content'
|
||||
import { CheckSquare, Clock, AlertCircle, MessageSquare, CheckCircle2, RotateCcw, Eye, CalendarRange, ArrowRightLeft, Search, X, Building2, Plus, Ban, ArrowUpDown, ArrowUp, ArrowDown, Filter, Pencil, ChevronDown } from 'lucide-react'
|
||||
import { CheckSquare, Clock, AlertCircle, MessageSquare, CheckCircle2, RotateCcw, Eye, CalendarRange, ArrowRightLeft, Search, X, Building2, Plus, Ban, ArrowUpDown, ArrowUp, ArrowDown, Filter, Pencil, ChevronDown, Info } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { formatDate, formatRenewalDate } from '@/lib/utils'
|
||||
|
|
@ -47,6 +47,11 @@ interface Task {
|
|||
policyGroup: { id: string; name: string | null; renewalDate: string | Date | null } | null
|
||||
assignments: TaskAssignment[]
|
||||
taskNotes: TaskNote[]
|
||||
daysOffset?: number | null
|
||||
timing?: string | null
|
||||
templateId?: string | null
|
||||
isAdHoc?: boolean | null
|
||||
template?: { id?: string; name: string; level?: string } | null
|
||||
}
|
||||
|
||||
interface SimpleUser { id: string; displayName: string | null; email: string; department?: string | null }
|
||||
|
|
@ -87,7 +92,20 @@ function PriorityDot({ priority }: { priority: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function TaskCard({ task: initial }: { task: Task }) {
|
||||
function buildOriginLabel(task: Task): string {
|
||||
if (task.isAdHoc) return 'Ad hoc task, added manually'
|
||||
if (!task.templateId) return 'Template-generated task (template details unavailable)'
|
||||
const days = task.daysOffset ?? 0
|
||||
const absDays = Math.abs(days)
|
||||
const timing = task.timing === 'POST_RENEWAL'
|
||||
? `${absDays} day${absDays !== 1 ? 's' : ''} after renewal`
|
||||
: `${absDays} day${absDays !== 1 ? 's' : ''} before renewal`
|
||||
const level = task.policyGroup ? 'Group level' : task.policy ? 'Policy level' : 'Client level'
|
||||
const templateName = task.template?.name ?? task.title
|
||||
return `${timing} · ${level} · Template: ${templateName}`
|
||||
}
|
||||
|
||||
function TaskCard({ task: initial, isPrivileged = false }: { task: Task; isPrivileged?: boolean }) {
|
||||
const [task, setTask] = useState(initial)
|
||||
const [notes, setNotes] = useState<TaskNote[]>(initial.taskNotes ?? [])
|
||||
const [noteOpen, setNoteOpen] = useState(false)
|
||||
|
|
@ -403,8 +421,22 @@ function TaskCard({ task: initial }: { task: Task }) {
|
|||
<div className="border-b border-l border-border" />
|
||||
)}
|
||||
{/* Row 3: Level | Edit */}
|
||||
<div className="px-3 py-1.5 border-b border-border flex items-center text-muted-foreground">
|
||||
<div className="px-3 py-1.5 border-b border-border flex items-center gap-1.5 text-muted-foreground">
|
||||
{levelLabel}
|
||||
{isPrivileged && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-default">
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground/60 hover:text-muted-foreground" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs text-xs">
|
||||
{buildOriginLabel(task)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
|
@ -516,6 +548,7 @@ function TaskCard({ task: initial }: { task: Task }) {
|
|||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
task={task as unknown as EditableTask}
|
||||
isPrivileged={isPrivileged}
|
||||
onUpdated={(updated) => {
|
||||
setTask((prev) => ({ ...prev, ...updated }))
|
||||
}}
|
||||
|
|
@ -1046,7 +1079,7 @@ export function TasksClient({ initialTasks, currentUserId, isPrivileged, users }
|
|||
) : (
|
||||
<div className="space-y-3">
|
||||
{visibleTasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
<TaskCard key={task.id} task={task} isPrivileged={isPrivileged} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export default async function TasksPage() {
|
|||
client: { select: { id: true, name: true } },
|
||||
policy: { select: { id: true, policyNumber: true, policyType: true, expirationDate: true, carrierName: true, writingCompanyName: true } },
|
||||
policyGroup: { select: { id: true, name: true, renewalDate: true } },
|
||||
template: { select: { id: true, name: true, level: true } },
|
||||
assignments: {
|
||||
include: { user: { select: { displayName: true, email: true } } },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -35,12 +35,17 @@ export async function GET(
|
|||
{ policyGroupId: null },
|
||||
{ policyGroup: { renewalDate: { gte: cutoff } } },
|
||||
]
|
||||
// Only show recent or terminal tasks
|
||||
// Keep ALL open tasks regardless of age; only hide ancient terminal tasks
|
||||
where.AND = [
|
||||
{
|
||||
OR: [
|
||||
{ dueDate: { gte: cutoff } },
|
||||
{ status: { in: ['COMPLETED', 'CANCELLED', 'NA'] } },
|
||||
{ status: { notIn: ['COMPLETED', 'CANCELLED', 'NA'] } },
|
||||
{
|
||||
AND: [
|
||||
{ status: { in: ['COMPLETED', 'CANCELLED', 'NA'] } },
|
||||
{ dueDate: { gte: cutoff } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
@ -67,6 +72,9 @@ export async function GET(
|
|||
policyGroup: {
|
||||
select: { id: true, name: true, renewalDate: true },
|
||||
},
|
||||
template: {
|
||||
select: { id: true, name: true, level: true },
|
||||
},
|
||||
taskNotes: {
|
||||
include: { user: { select: { id: true, displayName: true, email: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
|
|
|
|||
|
|
@ -671,6 +671,7 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
|
|||
key={task.id}
|
||||
task={task}
|
||||
onUpdated={() => refreshTasks()}
|
||||
isPrivileged={canManageGroups}
|
||||
/>
|
||||
))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { MessageSquare, CheckCircle2, RotateCcw, Ban, Pencil, ChevronDown, X } from 'lucide-react'
|
||||
import { MessageSquare, CheckCircle2, RotateCcw, Ban, Pencil, ChevronDown, X, Info } from 'lucide-react'
|
||||
import { formatDate, formatRenewalDate } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import { TaskEditModal, type EditableTask } from '@/components/tasks/task-edit-modal'
|
||||
|
|
@ -42,6 +42,10 @@ export interface TaskCardTask {
|
|||
isAdHoc?: boolean
|
||||
taskGroup?: string | null
|
||||
anchorGroup?: { id: string; name: string; renewalDate: string | Date } | null
|
||||
daysOffset?: number | null
|
||||
timing?: string | null
|
||||
templateId?: string | null
|
||||
template?: { id?: string; name: string; level?: string } | null
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
|
|
@ -73,13 +77,27 @@ function PriorityDot({ priority }: { priority: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function buildOriginLabel(task: TaskCardTask): string {
|
||||
if (task.isAdHoc) return 'Ad hoc task, added manually'
|
||||
if (!task.templateId) return 'Template-generated task (template details unavailable)'
|
||||
const days = task.daysOffset ?? 0
|
||||
const absDays = Math.abs(days)
|
||||
const timing = task.timing === 'POST_RENEWAL'
|
||||
? `${absDays} day${absDays !== 1 ? 's' : ''} after renewal`
|
||||
: `${absDays} day${absDays !== 1 ? 's' : ''} before renewal`
|
||||
const level = task.policyGroup ? 'Group level' : task.policy ? 'Policy level' : 'Client level'
|
||||
const templateName = task.template?.name ?? task.title
|
||||
return `${timing} · ${level} · Template: ${templateName}`
|
||||
}
|
||||
|
||||
interface TaskCardProps {
|
||||
task: TaskCardTask
|
||||
onUpdated?: (updated: Partial<TaskCardTask>) => void
|
||||
showClient?: boolean
|
||||
isPrivileged?: boolean
|
||||
}
|
||||
|
||||
export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskCardProps) {
|
||||
export function TaskCard({ task: initial, onUpdated, showClient = false, isPrivileged = false }: TaskCardProps) {
|
||||
const [task, setTask] = useState(initial)
|
||||
const [notes, setNotes] = useState(initial.taskNotes ?? [])
|
||||
const [noteOpen, setNoteOpen] = useState(false)
|
||||
|
|
@ -406,8 +424,22 @@ export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskC
|
|||
<div className="border-b border-l border-border" />
|
||||
)}
|
||||
{/* Row 3: Level | Edit */}
|
||||
<div className="px-3 py-1.5 border-b border-border flex items-center text-muted-foreground">
|
||||
<div className="px-3 py-1.5 border-b border-border flex items-center gap-1.5 text-muted-foreground">
|
||||
{levelLabel}
|
||||
{isPrivileged && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-default">
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground/60 hover:text-muted-foreground" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs text-xs">
|
||||
{buildOriginLabel(task)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { UserSelectContent } from '@/components/ui/user-select-content'
|
||||
import { Pencil } from 'lucide-react'
|
||||
import { Pencil, Info } from 'lucide-react'
|
||||
|
||||
interface SimpleUser {
|
||||
id: string
|
||||
|
|
@ -56,6 +56,24 @@ export interface EditableTask {
|
|||
policy?: { id: string; policyNumber: string | null; policyType: string | null; expirationDate?: string | Date | null } | null
|
||||
policyGroup?: { id: string; name: string | null; renewalDate?: string | Date | null } | null
|
||||
assignments: Array<{ id?: string; user: { id?: string; displayName: string | null; email: string }; userId?: string }>
|
||||
daysOffset?: number | null
|
||||
timing?: string | null
|
||||
templateId?: string | null
|
||||
isAdHoc?: boolean | null
|
||||
template?: { id?: string; name: string; level?: string } | null
|
||||
}
|
||||
|
||||
function buildOriginLabel(task: EditableTask): string {
|
||||
if (task.isAdHoc) return 'Ad hoc task, added manually'
|
||||
if (!task.templateId) return 'Template-generated task (template details unavailable)'
|
||||
const days = task.daysOffset ?? 0
|
||||
const absDays = Math.abs(days)
|
||||
const timing = task.timing === 'POST_RENEWAL'
|
||||
? `${absDays} day${absDays !== 1 ? 's' : ''} after renewal`
|
||||
: `${absDays} day${absDays !== 1 ? 's' : ''} before renewal`
|
||||
const level = task.policyGroupId ? 'Group level' : task.policyId ? 'Policy level' : 'Client level'
|
||||
const templateName = task.template?.name ?? task.title
|
||||
return `${timing} · ${level} · Template: ${templateName}`
|
||||
}
|
||||
|
||||
interface TaskEditModalProps {
|
||||
|
|
@ -63,6 +81,7 @@ interface TaskEditModalProps {
|
|||
onOpenChange: (open: boolean) => void
|
||||
task: EditableTask
|
||||
onUpdated: (updated: any) => void
|
||||
isPrivileged?: boolean
|
||||
}
|
||||
|
||||
const PRIORITIES = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']
|
||||
|
|
@ -74,7 +93,7 @@ const DEPARTMENTS = [
|
|||
{ value: 'OTHER', label: 'Other' },
|
||||
]
|
||||
|
||||
export function TaskEditModal({ open, onOpenChange, task, onUpdated }: TaskEditModalProps) {
|
||||
export function TaskEditModal({ open, onOpenChange, task, onUpdated, isPrivileged = false }: TaskEditModalProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [priority, setPriority] = useState('MEDIUM')
|
||||
|
|
@ -236,6 +255,14 @@ export function TaskEditModal({ open, onOpenChange, task, onUpdated }: TaskEditM
|
|||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Task Origin — managers/admins only */}
|
||||
{isPrivileged && (
|
||||
<div className="flex items-start gap-2 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{buildOriginLabel(task)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Title *</label>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue