feat(CC-072): add Calendar & Deadlines view (upcoming/overdue/month views)

This commit is contained in:
admin-valentin 2026-08-02 12:32:21 +00:00
parent 8050be8a9c
commit 47daf49dd2

View file

@ -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<string, string> = {
task: '✅', obligation: '⚖️', contract: '📝', goal: '🎯',
};
const TYPE_CLS: Record<string, string> = {
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<Task[]>('/v1/tasks?limit=200', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: obligations = [] } = useQuery({
queryKey: ['obligations-cal', tenantId],
queryFn: () => apiFetch<Obligation[]>('/v1/obligations?limit=200', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: contracts = [] } = useQuery({
queryKey: ['contracts-cal', tenantId],
queryFn: () => apiFetch<Contract[]>('/v1/contracts?status=active&limit=100', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const { data: goals = [] } = useQuery({
queryKey: ['goals-cal', tenantId],
queryFn: () => apiFetch<Goal[]>('/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<number, CalEvent[]> = {};
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 (
<div className="max-w-4xl space-y-6 p-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Calendar & Termene</h1>
<p className="text-sm text-ink-faint mt-1">
{overdue.length > 0 && <span className="text-signal-danger font-semibold">{overdue.length} depășite · </span>}
{upcoming.length} termene în 90 zile · {events.length} total
</p>
</div>
<div className="flex gap-1 border border-border rounded-lg overflow-hidden text-xs">
{(['upcoming', 'overdue', 'month'] as const).map((v) => (
<button key={v} onClick={() => setView(v)}
className={`px-3 py-1.5 transition-colors ${view === v ? 'bg-primary/10 text-ink font-medium' : 'text-ink-faint hover:text-ink'}`}>
{v === 'upcoming' ? 'Urmează' : v === 'overdue' ? `Depășite${overdue.length > 0 ? ` (${overdue.length})` : ''}` : 'Lună'}
</button>
))}
</div>
</div>
{/* Legend */}
<div className="flex flex-wrap gap-4">
{Object.entries(TYPE_ICON).map(([type, icon]) => (
<div key={type} className="flex items-center gap-1 text-[10px] text-ink-faint">
<span>{icon}</span> <span className="capitalize">{type}</span>
</div>
))}
</div>
{view === 'upcoming' && (
<div className="space-y-2">
{upcoming.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl">🗓</p>
<p className="text-sm text-ink-faint">Niciun termen în 90 zile.</p>
</div>
) : (
<div className="space-y-1">
{upcoming.map((e) => {
const days = daysUntil(e.date);
return (
<Link key={e.id} href={e.href}
className={`card px-4 py-3 flex items-center gap-3 border-l-4 hover:bg-muted/30 transition-colors ${TYPE_CLS[e.type]}`}>
<span className="text-base">{TYPE_ICON[e.type]}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-ink truncate">{e.title}</p>
{e.extra && <p className="text-[10px] text-ink-faint">{e.extra}</p>}
</div>
<div className="text-right shrink-0">
<p className="text-xs font-mono text-ink">
{e.date.toLocaleDateString('ro-RO', { day: '2-digit', month: 'short' })}
</p>
<p className={`text-[10px] ${days <= 7 ? 'text-signal-danger font-semibold' : days <= 30 ? 'text-warn' : 'text-ink-faint'}`}>
{days === 0 ? 'azi' : days === 1 ? 'mâine' : `${days}z`}
</p>
</div>
</Link>
);
})}
</div>
)}
</div>
)}
{view === 'overdue' && (
<div className="space-y-2">
{overdue.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl"></p>
<p className="text-sm text-ink-faint">Nicio depășire. Excelent.</p>
</div>
) : (
<div className="space-y-1">
{[...overdue].reverse().map((e) => {
const days = Math.abs(daysUntil(e.date));
return (
<Link key={e.id} href={e.href}
className={`card px-4 py-3 flex items-center gap-3 border-l-4 border-l-signal-danger hover:bg-signal-danger/5 transition-colors`}>
<span className="text-base">{TYPE_ICON[e.type]}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-ink truncate">{e.title}</p>
{e.extra && <p className="text-[10px] text-ink-faint">{e.extra}</p>}
</div>
<div className="text-right shrink-0">
<p className="text-xs font-mono text-ink">
{e.date.toLocaleDateString('ro-RO', { day: '2-digit', month: 'short' })}
</p>
<p className="text-[10px] text-signal-danger font-semibold">{days}z</p>
</div>
</Link>
);
})}
</div>
)}
</div>
)}
{view === 'month' && (
<div className="space-y-4">
{/* Month nav */}
<div className="flex items-center justify-between">
<button onClick={() => setMonthOffset((m) => m - 1)}
className="rounded-lg border px-3 py-1 text-sm hover:bg-muted/50"></button>
<span className="text-sm font-semibold text-ink">{MONTHS[viewMonth]} {viewYear}</span>
<button onClick={() => setMonthOffset((m) => m + 1)}
className="rounded-lg border px-3 py-1 text-sm hover:bg-muted/50"></button>
</div>
{/* Calendar grid */}
<div className="card overflow-hidden">
<div className="grid grid-cols-7 bg-muted/30">
{['Du','Lu','Ma','Mi','Jo','Vi','Sâ'].map((d) => (
<div key={d} className="p-2 text-center text-[10px] font-semibold text-ink-faint uppercase">{d}</div>
))}
</div>
<div className="grid grid-cols-7 border-t border-border/50">
{/* Empty cells for first dow */}
{Array.from({ length: firstDow }).map((_, i) => (
<div key={`e${i}`} className="min-h-[80px] border-r border-b border-border/30 bg-muted/10" />
))}
{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 (
<div key={day}
className={`min-h-[80px] border-r border-b border-border/30 p-1 ${isToday ? 'bg-primary/5' : ''}`}>
<p className={`text-[10px] font-semibold mb-1 ${isToday ? 'text-primary' : 'text-ink-faint'}`}>{day}</p>
{dayEvents.slice(0, 3).map((e) => (
<Link key={e.id} href={e.href}
className="block truncate rounded px-1 py-0.5 text-[9px] font-medium text-ink hover:bg-muted mb-0.5"
title={e.title}>
{TYPE_ICON[e.type]} {e.title}
</Link>
))}
{dayEvents.length > 3 && (
<p className="text-[9px] text-ink-faint">+{dayEvents.length - 3}</p>
)}
</div>
);
})}
</div>
</div>
</div>
)}
</div>
);
}