ceo-web/src/app/dashboard/calendar/page.tsx

256 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}