Follow-up fixes: starred group, edit pencil, sync warning, service type

- Starred group: PolicyGroupManager now uses client.renewalDate to identify anchor, not earliest date
- Setup API: recalculates client-level task due dates when starred group changes
- Client tasks API: uses client.renewalDate to pick anchor group, fallback to earliest
- PolicyGroupManager: fixed IIFE double-close that broke pencil/edit button
- Template sync: returns levelWarning when level changed; UI shows toast.warning
- Additional Services: added Service Type toggle (Service / Reminder); stored as taskGroup
- TaskCard: shows Service/Reminder badge on ad-hoc tasks
This commit is contained in:
lorentz 2026-04-24 19:56:44 +00:00
parent 8b43a25f4d
commit b44bddfc6a
8 changed files with 97 additions and 15 deletions

View file

@ -56,9 +56,10 @@ async function handleSave(
const existingClient = await prisma.client.findUnique({ const existingClient = await prisma.client.findUnique({
where: { id: clientId }, where: { id: clientId },
select: { claimsAdvocateId: true }, select: { claimsAdvocateId: true, renewalDate: true },
}) })
const previousAdvocateId = existingClient?.claimsAdvocateId const previousAdvocateId = existingClient?.claimsAdvocateId
const previousRenewalDate = existingClient?.renewalDate
const defaultGroup = body.groups.find((g) => g.isDefault) ?? body.groups[0] const defaultGroup = body.groups.find((g) => g.isDefault) ?? body.groups[0]
const defaultRenewalDate = defaultGroup const defaultRenewalDate = defaultGroup
@ -133,6 +134,29 @@ async function handleSave(
select: { id: true, name: true, renewalDate: true, setupCompletedAt: true, claimsAdvocateId: true }, select: { id: true, name: true, renewalDate: true, setupCompletedAt: true, claimsAdvocateId: true },
}) })
// If starred group changed, recalculate due dates on open client-level template tasks
const renewalDateChanged =
defaultRenewalDate &&
(!previousRenewalDate || defaultRenewalDate.getTime() !== previousRenewalDate.getTime())
if (renewalDateChanged && defaultRenewalDate) {
const openClientTasks = await prisma.task.findMany({
where: {
clientId,
policyId: null,
policyGroupId: null,
templateId: { not: null },
status: { in: ['NOT_STARTED', 'IN_PROGRESS', 'BLOCKED'] },
},
select: { id: true, daysOffset: true },
})
for (const t of openClientTasks) {
const dueDate = new Date(defaultRenewalDate)
dueDate.setDate(dueDate.getDate() + t.daysOffset)
await prisma.task.update({ where: { id: t.id }, data: { dueDate } })
}
}
const advocateChanged = body.claimsAdvocateId && body.claimsAdvocateId !== previousAdvocateId const advocateChanged = body.claimsAdvocateId && body.claimsAdvocateId !== previousAdvocateId
const auditEntries: Promise<any>[] = [ const auditEntries: Promise<any>[] = [

View file

@ -75,15 +75,31 @@ export async function GET(
orderBy: { dueDate: 'asc' }, orderBy: { dueDate: 'asc' },
}) })
// For client-level tasks (no policy, no group), attach the anchor group (earliest renewalDate) // For client-level tasks (no policy, no group), attach the anchor group
// Prefer the group matching client.renewalDate (set by setup wizard star), else earliest
const hasClientLevelTasks = tasks.some((t) => !t.policyId && !t.policyGroupId) const hasClientLevelTasks = tasks.some((t) => !t.policyId && !t.policyGroupId)
let anchorGroup: { id: string; name: string; renewalDate: Date } | null = null let anchorGroup: { id: string; name: string; renewalDate: Date } | null = null
if (hasClientLevelTasks) { if (hasClientLevelTasks) {
anchorGroup = await prisma.policyGroup.findFirst({ const clientRecord = await prisma.client.findUnique({
where: { clientId: id }, where: { id },
orderBy: { renewalDate: 'asc' }, select: { renewalDate: true },
select: { id: true, name: true, renewalDate: true },
}) })
if (clientRecord?.renewalDate) {
anchorGroup = await prisma.policyGroup.findFirst({
where: {
clientId: id,
renewalDate: clientRecord.renewalDate,
},
select: { id: true, name: true, renewalDate: true },
})
}
if (!anchorGroup) {
anchorGroup = await prisma.policyGroup.findFirst({
where: { clientId: id },
orderBy: { renewalDate: 'asc' },
select: { id: true, name: true, renewalDate: true },
})
}
} }
const enriched = tasks.map((t) => ({ const enriched = tasks.map((t) => ({

View file

@ -51,6 +51,15 @@ export async function POST(
return NextResponse.json({ updated: 0, message: 'No open tasks to update' }) return NextResponse.json({ updated: 0, message: 'No open tasks to update' })
} }
// Warn if the template level was changed — sync can update fields but cannot
// reassign tasks from one level (group/policy/client) to another.
const levelWarning =
(template.level === 'CLIENT' && openTasks.some((t) => t.policyGroupId || t.policyId))
? 'Template level was changed to Client, but existing tasks remain at their original level. Newly generated tasks will follow the new level.'
: (template.level === 'RENEWAL_GROUP' && openTasks.some((t) => t.policyId))
? 'Template level was changed to Group, but existing policy-level tasks remain unchanged. Newly generated tasks will follow the new level.'
: null
const daysOffsetChanged = template.daysOffset !== openTasks[0]?.daysOffset const daysOffsetChanged = template.daysOffset !== openTasks[0]?.daysOffset
let updated = 0 let updated = 0
@ -93,7 +102,11 @@ export async function POST(
}, },
}) })
return NextResponse.json({ updated, message: `${updated} open task(s) updated` }) return NextResponse.json({
updated,
message: `${updated} open task(s) updated`,
...(levelWarning ? { warning: levelWarning } : {}),
})
} catch (error) { } catch (error) {
console.error('Template sync error:', error) console.error('Template sync error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 })

View file

@ -278,6 +278,7 @@ export function TaskTemplateManager({
const data = await res.json() const data = await res.json()
if (!res.ok) throw new Error(data.error) if (!res.ok) throw new Error(data.error)
toast.success(data.message) toast.success(data.message)
if (data.warning) toast.warning(data.warning)
setSyncConfirmId(null) setSyncConfirmId(null)
setSyncCount(null) setSyncCount(null)
} catch (err: any) { } catch (err: any) {

View file

@ -705,6 +705,7 @@ export function ClientDetail({ client, designations, policyGroups = [], allPolic
initialGroups={policyGroups} initialGroups={policyGroups}
allPolicies={allPolicies ?? client.policies} allPolicies={allPolicies ?? client.policies}
canManage={canManageGroups} canManage={canManageGroups}
clientRenewalDate={client.renewalDate ?? null}
onTasksGenerated={() => refreshTasks()} onTasksGenerated={() => refreshTasks()}
/> />
</TabsContent> </TabsContent>

View file

@ -68,6 +68,7 @@ interface PolicyGroupManagerProps {
initialGroups: PolicyGroup[] initialGroups: PolicyGroup[]
allPolicies: Policy[] allPolicies: Policy[]
canManage: boolean canManage: boolean
clientRenewalDate?: string | null
onTasksGenerated?: (count: number) => void onTasksGenerated?: (count: number) => void
} }
@ -83,6 +84,7 @@ export function PolicyGroupManager({
initialGroups, initialGroups,
allPolicies, allPolicies,
canManage, canManage,
clientRenewalDate,
onTasksGenerated, onTasksGenerated,
}: PolicyGroupManagerProps) { }: PolicyGroupManagerProps) {
const [groups, setGroups] = useState<PolicyGroup[]>(initialGroups) const [groups, setGroups] = useState<PolicyGroup[]>(initialGroups)
@ -361,13 +363,11 @@ export function PolicyGroupManager({
</Card> </Card>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{(() => { {groups.map((group) => {
const anchorGroupId = groups.length > 0
? groups.reduce((a, b) => new Date(a.renewalDate) <= new Date(b.renewalDate) ? a : b).id
: null
return groups.map((group) => {
const isExpanded = expandedGroupId === group.id const isExpanded = expandedGroupId === group.id
const isAnchor = group.id === anchorGroupId const isAnchor = clientRenewalDate
? new Date(group.renewalDate).toDateString() === new Date(clientRenewalDate).toDateString()
: groups.length > 0 && group.id === groups.reduce((a, b) => new Date(a.renewalDate) <= new Date(b.renewalDate) ? a : b).id
return ( return (
<Card key={group.id}> <Card key={group.id}>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@ -487,8 +487,7 @@ export function PolicyGroupManager({
)} )}
</Card> </Card>
) )
}) })}
})()}
</div> </div>
)} )}

View file

@ -84,6 +84,7 @@ export function AdditionalServiceModal({
onCreated, onCreated,
}: AdditionalServiceModalProps) { }: AdditionalServiceModalProps) {
const [level, setLevel] = useState<'client' | 'policy' | 'group'>('client') const [level, setLevel] = useState<'client' | 'policy' | 'group'>('client')
const [serviceType, setServiceType] = useState<'SERVICE' | 'REMINDER'>('SERVICE')
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [priority, setPriority] = useState('MEDIUM') const [priority, setPriority] = useState('MEDIUM')
@ -119,6 +120,7 @@ export function AdditionalServiceModal({
setDueDate('') setDueDate('')
setAssignTo(currentUserId) setAssignTo(currentUserId)
setLevel(context?.policyId ? 'policy' : context?.policyGroupId ? 'group' : 'client') setLevel(context?.policyId ? 'policy' : context?.policyGroupId ? 'group' : 'client')
setServiceType('SERVICE')
setClientId(context?.clientId ?? '') setClientId(context?.clientId ?? '')
setClientSearch(context?.clientName ?? '') setClientSearch(context?.clientName ?? '')
setSelectedPolicyId(context?.policyId ?? '') setSelectedPolicyId(context?.policyId ?? '')
@ -189,6 +191,7 @@ export function AdditionalServiceModal({
daysOffset: 0, daysOffset: 0,
status: 'NOT_STARTED', status: 'NOT_STARTED',
isAdHoc: true, isAdHoc: true,
taskGroup: serviceType,
clientId, clientId,
assignedUserIds: [assignTo], assignedUserIds: [assignTo],
} }
@ -293,6 +296,24 @@ export function AdditionalServiceModal({
</div> </div>
)} )}
{/* Service Type */}
<div className="space-y-1.5">
<label className="text-sm font-medium">Type</label>
<div className="flex gap-2">
{(['SERVICE', 'REMINDER'] as const).map((t) => (
<button
key={t}
onClick={() => setServiceType(t)}
className={`flex-1 py-1.5 rounded-md border text-sm transition-colors ${
serviceType === t ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted'
}`}
>
{t === 'SERVICE' ? 'Service' : 'Reminder / Follow-up'}
</button>
))}
</div>
</div>
{/* Level */} {/* Level */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-medium">Associate at level</label> <label className="text-sm font-medium">Associate at level</label>

View file

@ -40,6 +40,7 @@ export interface TaskCardTask {
assignments: { id: string; user: { displayName: string | null; email: string } }[] assignments: { id: string; user: { displayName: string | null; email: string } }[]
taskNotes?: { id: string; content: string; createdAt: string; user: { id: string; displayName: string | null; email: string } }[] taskNotes?: { id: string; content: string; createdAt: string; user: { id: string; displayName: string | null; email: string } }[]
isAdHoc?: boolean isAdHoc?: boolean
taskGroup?: string | null
anchorGroup?: { id: string; name: string; renewalDate: string | Date } | null anchorGroup?: { id: string; name: string; renewalDate: string | Date } | null
} }
@ -275,6 +276,12 @@ export function TaskCard({ task: initial, onUpdated, showClient = false }: TaskC
{task.isAdHoc && ( {task.isAdHoc && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-700 dark:text-purple-400 font-medium shrink-0">Ad hoc</span> <span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/15 text-purple-700 dark:text-purple-400 font-medium shrink-0">Ad hoc</span>
)} )}
{task.isAdHoc && task.taskGroup === 'REMINDER' && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-orange-500/15 text-orange-700 dark:text-orange-400 font-medium shrink-0">Reminder</span>
)}
{task.isAdHoc && task.taskGroup === 'SERVICE' && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-700 dark:text-blue-400 font-medium shrink-0">Service</span>
)}
</div> </div>
{showClient && task.client && ( {showClient && task.client && (
<Link <Link