From 47daf49dd2604a30ed6c8bdd0cad9135e9a2fba1 Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sun, 2 Aug 2026 12:32:21 +0000 Subject: [PATCH] feat(CC-072): add Calendar & Deadlines view (upcoming/overdue/month views) --- src/app/dashboard/calendar/page.tsx | 256 ++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 src/app/dashboard/calendar/page.tsx diff --git a/src/app/dashboard/calendar/page.tsx b/src/app/dashboard/calendar/page.tsx new file mode 100644 index 0000000..10d862c --- /dev/null +++ b/src/app/dashboard/calendar/page.tsx @@ -0,0 +1,256 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import Link from 'next/link'; +import { apiFetch } from '../../../lib/api'; +import { useSession } from '../../../components/session-provider'; + +interface Task { + id: string; title: string; status: string; priority: string; dueDate: string | null; assignedTo: string | null; +} +interface Obligation { + id: string; title: string; status: string; priority: string | null; dueDate: string | null; + obligationType: string | null; recurrence: string | null; +} +interface Contract { + id: string; title: string; status: string; expiresAt: string | null; counterpartyName: string | null; +} +interface Goal { + id: string; title: string; status: string; targetDate: string | null; +} + +type CalEvent = { + id: string; type: 'task' | 'obligation' | 'contract' | 'goal'; title: string; + date: Date; priority?: string; extra?: string; href: string; +}; + +const TYPE_ICON: Record = { + task: '✅', obligation: '⚖️', contract: '📝', goal: '🎯', +}; +const TYPE_CLS: Record = { + task: 'border-l-primary', obligation: 'border-l-warn', contract: 'border-l-signal-ok', goal: 'border-l-violet-500', +}; +const MONTHS = ['Ian','Feb','Mar','Apr','Mai','Iun','Iul','Aug','Sep','Oct','Nov','Dec']; + +function daysUntil(d: Date) { + return Math.ceil((d.getTime() - Date.now()) / 86400_000); +} + +export default function CalendarPage() { + const { activeTenant } = useSession(); + const tenantId = activeTenant?.tenantId ?? ''; + const [view, setView] = useState<'upcoming' | 'overdue' | 'month'>('upcoming'); + const now = new Date(); + const [monthOffset, setMonthOffset] = useState(0); + + const { data: tasks = [] } = useQuery({ + queryKey: ['tasks-cal', tenantId], + queryFn: () => apiFetch('/v1/tasks?limit=200', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: obligations = [] } = useQuery({ + queryKey: ['obligations-cal', tenantId], + queryFn: () => apiFetch('/v1/obligations?limit=200', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: contracts = [] } = useQuery({ + queryKey: ['contracts-cal', tenantId], + queryFn: () => apiFetch('/v1/contracts?status=active&limit=100', { tenantId }), + enabled: Boolean(tenantId), staleTime: 120_000, + }); + const { data: goals = [] } = useQuery({ + queryKey: ['goals-cal', tenantId], + queryFn: () => apiFetch('/v1/goals?status=active&limit=100', { tenantId }), + enabled: Boolean(tenantId), staleTime: 120_000, + }); + + const events: CalEvent[] = useMemo(() => { + const evts: CalEvent[] = []; + for (const t of tasks) { + if (t.dueDate && t.status !== 'completed' && t.status !== 'cancelled') { + evts.push({ id: t.id, type: 'task', title: t.title, date: new Date(t.dueDate), priority: t.priority, href: '/dashboard/tasks' }); + } + } + for (const o of obligations) { + if (o.dueDate && o.status !== 'completed') { + evts.push({ id: o.id, type: 'obligation', title: o.title, date: new Date(o.dueDate), priority: o.priority ?? 'medium', extra: o.obligationType ?? undefined, href: '/dashboard/obligations' }); + } + } + for (const c of contracts) { + if (c.expiresAt) { + evts.push({ id: c.id, type: 'contract', title: c.title, date: new Date(c.expiresAt), extra: c.counterpartyName ?? undefined, href: '/dashboard/contracts' }); + } + } + for (const g of goals) { + if (g.targetDate) { + evts.push({ id: g.id, type: 'goal', title: g.title, date: new Date(g.targetDate), href: '/dashboard/goals' }); + } + } + return evts.sort((a, b) => a.date.getTime() - b.date.getTime()); + }, [tasks, obligations, contracts, goals]); + + const overdue = events.filter((e) => e.date < now); + const upcoming = events.filter((e) => e.date >= now && daysUntil(e.date) <= 90); + + // Month view data + const viewDate = new Date(now.getFullYear(), now.getMonth() + monthOffset, 1); + const viewYear = viewDate.getFullYear(); + const viewMonth = viewDate.getMonth(); + const firstDow = new Date(viewYear, viewMonth, 1).getDay(); + const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); + const monthEvents: Record = {}; + for (const e of events) { + if (e.date.getFullYear() === viewYear && e.date.getMonth() === viewMonth) { + const day = e.date.getDate(); + monthEvents[day] = [...(monthEvents[day] ?? []), e]; + } + } + + return ( +
+
+
+

Calendar & Termene

+

+ {overdue.length > 0 && {overdue.length} depășite · } + {upcoming.length} termene în 90 zile · {events.length} total +

+
+
+ {(['upcoming', 'overdue', 'month'] as const).map((v) => ( + + ))} +
+
+ + {/* Legend */} +
+ {Object.entries(TYPE_ICON).map(([type, icon]) => ( +
+ {icon} {type} +
+ ))} +
+ + {view === 'upcoming' && ( +
+ {upcoming.length === 0 ? ( +
+

🗓️

+

Niciun termen în 90 zile.

+
+ ) : ( +
+ {upcoming.map((e) => { + const days = daysUntil(e.date); + return ( + + {TYPE_ICON[e.type]} +
+

{e.title}

+ {e.extra &&

{e.extra}

} +
+
+

+ {e.date.toLocaleDateString('ro-RO', { day: '2-digit', month: 'short' })} +

+

+ {days === 0 ? 'azi' : days === 1 ? 'mâine' : `${days}z`} +

+
+ + ); + })} +
+ )} +
+ )} + + {view === 'overdue' && ( +
+ {overdue.length === 0 ? ( +
+

+

Nicio depășire. Excelent.

+
+ ) : ( +
+ {[...overdue].reverse().map((e) => { + const days = Math.abs(daysUntil(e.date)); + return ( + + {TYPE_ICON[e.type]} +
+

{e.title}

+ {e.extra &&

{e.extra}

} +
+
+

+ {e.date.toLocaleDateString('ro-RO', { day: '2-digit', month: 'short' })} +

+

−{days}z

+
+ + ); + })} +
+ )} +
+ )} + + {view === 'month' && ( +
+ {/* Month nav */} +
+ + {MONTHS[viewMonth]} {viewYear} + +
+ + {/* Calendar grid */} +
+
+ {['Du','Lu','Ma','Mi','Jo','Vi','Sâ'].map((d) => ( +
{d}
+ ))} +
+
+ {/* Empty cells for first dow */} + {Array.from({ length: firstDow }).map((_, i) => ( +
+ ))} + {Array.from({ length: daysInMonth }, (_, i) => i + 1).map((day) => { + const isToday = day === now.getDate() && viewMonth === now.getMonth() && viewYear === now.getFullYear(); + const dayEvents = monthEvents[day] ?? []; + return ( +
+

{day}

+ {dayEvents.slice(0, 3).map((e) => ( + + {TYPE_ICON[e.type]} {e.title} + + ))} + {dayEvents.length > 3 && ( +

+{dayEvents.length - 3}

+ )} +
+ ); + })} +
+
+
+ )} +
+ ); +}