feat(CC-064): add Obligations page (deadlines/fiscal/legal with overdue tracking)
This commit is contained in:
parent
05ea4b849c
commit
545c4eb9f3
1 changed files with 170 additions and 0 deletions
170
src/app/dashboard/obligations/page.tsx
Normal file
170
src/app/dashboard/obligations/page.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiFetch } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
interface Obligation {
|
||||
id: string; title: string; description: string | null;
|
||||
category: string; status: string; dueDate: string | null;
|
||||
owner: string | null; riskLevel: string; createdAt: string;
|
||||
}
|
||||
|
||||
const CAT_META: Record<string, string> = {
|
||||
contractual: '📋 Contractual', fiscal: '🧾 Fiscal', legal: '⚖️ Legal', regulatory: '🏛️ Reglementar',
|
||||
};
|
||||
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
pending: { label: 'Pending', cls: 'bg-signal-warn/10 text-signal-warn' },
|
||||
fulfilled: { label: 'Îndeplinit', cls: 'bg-signal-ok/10 text-signal-ok' },
|
||||
overdue: { label: 'Restant', cls: 'bg-signal-danger/10 text-signal-danger' },
|
||||
waived: { label: 'Renunțat', cls: 'bg-muted text-ink-faint' },
|
||||
};
|
||||
const RISK_META: Record<string, { cls: string }> = {
|
||||
low: { cls: 'text-sky-600' }, medium: { cls: 'text-signal-warn' },
|
||||
high: { cls: 'text-orange-600' }, critical: { cls: 'text-signal-danger font-bold' },
|
||||
};
|
||||
|
||||
export default function ObligationsPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [filterStatus, setFilterStatus] = useState('pending');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({ title: '', category: 'contractual', dueDate: '', owner: '', riskLevel: 'medium' });
|
||||
|
||||
const { data: obligations = [], isLoading } = useQuery({
|
||||
queryKey: ['obligations', tenantId, filterStatus],
|
||||
queryFn: () => apiFetch<Obligation[]>(`/v1/obligations${filterStatus !== 'all' ? `?status=${filterStatus}` : ''}`, { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { mutate: create, isPending } = useMutation({
|
||||
mutationFn: () => apiFetch<Obligation>('/v1/obligations', { method: 'POST', body: form, tenantId }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['obligations', tenantId] });
|
||||
setShowCreate(false);
|
||||
setForm({ title: '', category: 'contractual', dueDate: '', owner: '', riskLevel: 'medium' });
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: fulfill } = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/v1/obligations/${id}`, { method: 'PATCH', body: { status: 'fulfilled' }, tenantId }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['obligations', tenantId] }),
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const overdueIds = new Set(
|
||||
obligations.filter((o) => o.dueDate && new Date(o.dueDate) < now && o.status === 'pending').map((o) => o.id)
|
||||
);
|
||||
const sorted = [...obligations].sort((a, b) => {
|
||||
if (!a.dueDate) return 1;
|
||||
if (!b.dueDate) return -1;
|
||||
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Termene & Obligații</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{overdueIds.size > 0 && <span className="text-signal-danger">{overdueIds.size} restante · </span>}
|
||||
{obligations.length} obligații
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)} className="btn btn-primary text-xs px-4 py-2">+ Obligație nouă</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="card p-5 space-y-4 border-primary/30">
|
||||
<h2 className="text-sm font-semibold text-ink">Obligație nouă</h2>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="text-xs text-ink-faint block mb-1">Titlu *</label>
|
||||
<input value={form.title} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||
placeholder="Depunere declarație TVA Q1…"
|
||||
className="w-full rounded-lg border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
{[
|
||||
{ key: 'category', label: 'Categorie', opts: Object.entries(CAT_META).map(([k, v]) => ({ value: k, label: v })) },
|
||||
{ key: 'riskLevel', label: 'Nivel risc', opts: [['low','Scăzut'],['medium','Mediu'],['high','Ridicat'],['critical','Critic']].map(([k, l]) => ({ value: k, label: l })) },
|
||||
].map(({ key, label, opts }) => (
|
||||
<div key={key}>
|
||||
<label className="text-xs text-ink-faint block mb-1">{label}</label>
|
||||
<select value={(form as Record<string, string>)[key]}
|
||||
onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||
className="w-full rounded-lg border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
{opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
{[{ key: 'dueDate', label: 'Termen', type: 'date' }, { key: 'owner', label: 'Responsabil', type: 'text' }].map(({key, label, type}) => (
|
||||
<div key={key}>
|
||||
<label className="text-xs text-ink-faint block mb-1">{label}</label>
|
||||
<input type={type} value={(form as Record<string, string>)[key]}
|
||||
onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||
className="w-full rounded-lg border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => create()} disabled={isPending || !form.title.trim()}
|
||||
className="btn btn-primary text-xs px-4 py-2 disabled:opacity-50">
|
||||
{isPending ? 'Se salvează…' : 'Salvează'}
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="text-xs text-ink-faint hover:text-ink">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[['pending','Pending'], ['fulfilled','Îndeplinite'], ['overdue','Restante'], ['all','Toate']].map(([s, l]) => (
|
||||
<button key={s} onClick={() => setFilterStatus(s)}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${filterStatus === s ? 'bg-primary text-white border-primary' : 'bg-card text-ink-faint border-border hover:border-primary/40'}`}>{l}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? <div className="card p-8 text-center text-sm text-ink-faint">Se încarcă…</div>
|
||||
: sorted.length === 0 ? (
|
||||
<div className="card p-12 text-center space-y-2">
|
||||
<p className="text-3xl">✅</p>
|
||||
<p className="text-sm text-ink-faint">Nicio obligație înregistrată.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sorted.map((ob) => {
|
||||
const status = STATUS_META[ob.status] ?? STATUS_META.pending;
|
||||
const risk = RISK_META[ob.riskLevel] ?? RISK_META.medium;
|
||||
const isOv = overdueIds.has(ob.id);
|
||||
return (
|
||||
<div key={ob.id} className={`card p-4 flex items-start gap-3 ${isOv ? 'border-signal-danger/30' : ''}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium text-ink">{ob.title}</p>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${status.cls}`}>{status.label}</span>
|
||||
{isOv && <span className="text-[10px] text-signal-danger font-bold">⏰ RESTANT</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs mt-0.5 flex-wrap">
|
||||
<span className="text-ink-faint">{CAT_META[ob.category] ?? ob.category}</span>
|
||||
{ob.dueDate && <span className={isOv ? 'text-signal-danger' : 'text-ink-faint'}>Termen: {ob.dueDate}</span>}
|
||||
{ob.owner && <span className="text-ink-faint">Resp.: {ob.owner}</span>}
|
||||
<span className={risk.cls}>Risc: {ob.riskLevel}</span>
|
||||
</div>
|
||||
</div>
|
||||
{ob.status === 'pending' && (
|
||||
<button onClick={() => fulfill(ob.id)}
|
||||
className="shrink-0 text-[10px] text-signal-ok border border-signal-ok/30 rounded px-2 py-1 hover:bg-signal-ok/5">
|
||||
✓ Îndeplinit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue