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:
lorentz 2026-05-19 03:49:39 +00:00
parent a4dfa0db59
commit 970ffe2864
7 changed files with 117 additions and 12 deletions

View file

@ -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: {

View file

@ -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>
)}

View file

@ -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 } } },
},

View file

@ -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: { 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' },

View file

@ -671,6 +671,7 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
key={task.id}
task={task}
onUpdated={() => refreshTasks()}
isPrivileged={canManageGroups}
/>
))
)

View file

@ -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>

View file

@ -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>