feat(CC-072): add Calendar & Deadlines view (upcoming/overdue/month views)
This commit is contained in:
parent
8050be8a9c
commit
47daf49dd2
1 changed files with 256 additions and 0 deletions
256
src/app/dashboard/calendar/page.tsx
Normal file
256
src/app/dashboard/calendar/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue