feat(CC-064): add Contracts page (list/create, expiry alerts, type/status filters)
This commit is contained in:
parent
d877e6e343
commit
05ea4b849c
1 changed files with 172 additions and 0 deletions
172
src/app/dashboard/contracts/page.tsx
Normal file
172
src/app/dashboard/contracts/page.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
'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 Contract {
|
||||
id: string; title: string; contractType: string; status: string;
|
||||
parties: string[]; organizationId: string | null;
|
||||
startDate: string | null; endDate: string | null;
|
||||
valueMinorUnits: number | null; currency: string | null;
|
||||
notes: string | null; tags: string[]; createdAt: string;
|
||||
}
|
||||
interface Organization { id: string; name: string; }
|
||||
|
||||
const TYPE_META: Record<string, string> = {
|
||||
revenue: '💰 Venituri', expense: '💸 Cheltuieli', nda: '🔒 NDA',
|
||||
employment: '👔 Angajare', other: '📄 Altele',
|
||||
};
|
||||
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||
draft: { label: 'Schiță', cls: 'bg-muted text-ink-faint' },
|
||||
active: { label: 'Activ', cls: 'bg-signal-ok/10 text-signal-ok' },
|
||||
expired: { label: 'Expirat', cls: 'bg-signal-danger/10 text-signal-danger' },
|
||||
terminated: { label: 'Reziliat', cls: 'bg-muted text-ink-faint' },
|
||||
};
|
||||
|
||||
export default function ContractsPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
title: '', contractType: 'other', startDate: '', endDate: '', parties: '', notes: '',
|
||||
});
|
||||
|
||||
const { data: contracts = [], isLoading } = useQuery({
|
||||
queryKey: ['contracts', tenantId, filterStatus],
|
||||
queryFn: () => apiFetch<Contract[]>(`/v1/contracts${filterStatus ? `?status=${filterStatus}` : ''}`, { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { mutate: create, isPending } = useMutation({
|
||||
mutationFn: () => apiFetch<Contract>('/v1/contracts', {
|
||||
method: 'POST',
|
||||
body: { ...form, parties: form.parties.split(',').map((p) => p.trim()).filter(Boolean) },
|
||||
tenantId,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['contracts', tenantId] });
|
||||
setShowCreate(false);
|
||||
setForm({ title: '', contractType: 'other', startDate: '', endDate: '', parties: '', notes: '' });
|
||||
},
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const expiringSoon = contracts.filter((c) => {
|
||||
if (!c.endDate || c.status !== 'active') return false;
|
||||
const exp = new Date(c.endDate);
|
||||
return exp < new Date(now.getTime() + 30 * 86400_000) && exp > now;
|
||||
});
|
||||
|
||||
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">Contracte</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">{contracts.length} contracte înregistrate</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)} className="btn btn-primary text-xs px-4 py-2">+ Contract nou</button>
|
||||
</div>
|
||||
|
||||
{expiringSoon.length > 0 && (
|
||||
<div className="rounded-xl border border-signal-warn/30 bg-signal-warn/5 p-4 space-y-1">
|
||||
<p className="text-sm font-semibold text-signal-warn">⚠ {expiringSoon.length} contracte expiră în 30 zile</p>
|
||||
{expiringSoon.map((c) => (
|
||||
<p key={c.id} className="text-xs text-ink-faint">{c.title} — expiră {c.endDate}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<div className="card p-5 space-y-4 border-primary/30">
|
||||
<h2 className="text-sm font-semibold text-ink">Contract 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="Contract prestări servicii…"
|
||||
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>
|
||||
<label className="text-xs text-ink-faint block mb-1">Tip</label>
|
||||
<select value={form.contractType} onChange={(e) => setForm((f) => ({ ...f, contractType: 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">
|
||||
{Object.entries(TYPE_META).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-faint block mb-1">Părți (separate prin virgulă)</label>
|
||||
<input value={form.parties} onChange={(e) => setForm((f) => ({ ...f, parties: e.target.value }))}
|
||||
placeholder="Firma A, Firma B"
|
||||
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: 'startDate', label: 'Data start'}, {key: 'endDate', label: 'Dată expirare'}].map(({key, label}) => (
|
||||
<div key={key}>
|
||||
<label className="text-xs text-ink-faint block mb-1">{label}</label>
|
||||
<input type="date" 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">
|
||||
{[['', 'Toate'], ['draft', 'Schiță'], ['active', 'Active'], ['expired', 'Expirate']].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>
|
||||
: contracts.length === 0 ? (
|
||||
<div className="card p-12 text-center space-y-2">
|
||||
<p className="text-3xl">📋</p>
|
||||
<p className="text-sm text-ink-faint">Niciun contract înregistrat.</p>
|
||||
<button onClick={() => setShowCreate(true)} className="text-xs text-bronze-deep hover:underline">Adaugă primul contract →</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50 rounded-xl border overflow-hidden">
|
||||
{contracts.map((c) => {
|
||||
const status = STATUS_META[c.status] ?? STATUS_META.draft;
|
||||
const isExpiring = expiringSoon.some((e) => e.id === c.id);
|
||||
return (
|
||||
<div key={c.id} className="flex items-center gap-4 px-4 py-3 bg-card hover:bg-muted/30 transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium text-ink">{c.title}</p>
|
||||
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${status.cls}`}>{status.label}</span>
|
||||
{isExpiring && <span className="text-[10px] text-signal-warn font-medium">Expiră curând</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 text-xs text-ink-faint">
|
||||
<span>{TYPE_META[c.contractType] ?? c.contractType}</span>
|
||||
{c.endDate && <span>⏳ {c.endDate}</span>}
|
||||
{c.parties.length > 0 && <span>{c.parties.slice(0, 2).join(', ')}{c.parties.length > 2 ? '…' : ''}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-ink-faint shrink-0">
|
||||
{new Date(c.createdAt).toLocaleDateString('ro-RO', { day: 'numeric', month: 'short' })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue