feat(CC-086): add Trust Disputes page (task-based incidents with resolution tracking)
This commit is contained in:
parent
a958fbb924
commit
a0f60e9495
1 changed files with 176 additions and 0 deletions
176
src/app/dashboard/trust/disputes/page.tsx
Normal file
176
src/app/dashboard/trust/disputes/page.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } 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; description: string | null; status: string; priority: string | null; tags: string[]; createdAt: string; }
|
||||
|
||||
const DISPUTE_TAGS = ['dispute', 'disputa', 'conflict', 'litigiu', 'litigation', 'incident', 'complaint', 'reclamatie', 'reclamație'];
|
||||
const RESOLUTION_STATUS: Record<string, { label: string; cls: string }> = {
|
||||
todo: { label: 'Deschis', cls: 'bg-signal-danger/10 text-signal-danger' },
|
||||
in_progress: { label: 'În negociere', cls: 'bg-warn/10 text-warn' },
|
||||
review: { label: 'În revizie', cls: 'bg-primary/10 text-primary' },
|
||||
completed: { label: 'Rezolvat', cls: 'bg-signal-ok/10 text-signal-ok' },
|
||||
cancelled: { label: 'Abandonat', cls: 'bg-muted text-ink-faint' },
|
||||
};
|
||||
|
||||
export default function TrustDisputesPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ title: '', description: '', type: 'dispute', priority: 'normal' });
|
||||
|
||||
const { data: tasks = [], isLoading } = useQuery({
|
||||
queryKey: ['disputes', tenantId],
|
||||
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const disputes = useMemo(() =>
|
||||
tasks.filter((t) => t.tags.some((tag) => DISPUTE_TAGS.includes(tag.toLowerCase())))
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
|
||||
[tasks]);
|
||||
|
||||
const open = disputes.filter((d) => !['completed', 'cancelled'].includes(d.status));
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => apiFetch('/v1/tasks', { tenantId, method: 'POST', body: {
|
||||
title: form.title, description: form.description || undefined,
|
||||
priority: form.priority, tags: [form.type, 'dispute'], status: 'todo',
|
||||
}}),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['disputes', tenantId] }); setShowAdd(false); setForm({ title: '', description: '', type: 'dispute', priority: 'normal' }); },
|
||||
});
|
||||
|
||||
const patchMut = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
apiFetch(`/v1/tasks/${id}`, { tenantId, method: 'PATCH', body: { status } }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['disputes', tenantId] }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6 p-6">
|
||||
<div className="flex items-start justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<nav className="text-xs text-ink-faint mb-1">
|
||||
<Link href="/dashboard/trust" className="hover:underline">Trust Dashboard</Link> / Dispute & Incidente
|
||||
</nav>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Dispute & Incidente</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{disputes.length} înregistrate · {open.length} deschise
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowAdd(true)}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
|
||||
+ Adaugă incident
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card p-3 bg-warn/5 border-warn/30">
|
||||
<p className="text-[10px] text-ink-faint">
|
||||
<strong>Confidențialitate:</strong> Înregistrările sunt stocate local în CEO OS și nu sunt partajate automat.
|
||||
Documentează pentru uz propriu — consultă un jurist pentru situații cu implicații legale.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{open.length > 0 && (
|
||||
<div className="card p-3 bg-signal-danger/5 border-signal-danger/30 flex items-center gap-3">
|
||||
<span className="text-xl">⚠️</span>
|
||||
<p className="text-sm font-medium text-signal-danger">{open.length} dispute/incidente deschise necesită atenție.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<p className="text-sm font-semibold text-ink">Incident / Dispută nouă</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<input placeholder="Titlu (ex: Neplată factură client X)" value={form.title}
|
||||
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring sm:col-span-2" />
|
||||
<select value={form.type} onChange={(e) => setForm((p) => ({ ...p, type: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<option value="dispute">Dispută comercială</option>
|
||||
<option value="litigiu">Litigiu / Legal</option>
|
||||
<option value="conflict">Conflict personal</option>
|
||||
<option value="reclamatie">Reclamație</option>
|
||||
<option value="incident">Incident securitate</option>
|
||||
</select>
|
||||
<select value={form.priority} onChange={(e) => setForm((p) => ({ ...p, priority: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<option value="low">Scăzută</option>
|
||||
<option value="normal">Normală</option>
|
||||
<option value="high">Înaltă</option>
|
||||
<option value="urgent">Urgentă</option>
|
||||
</select>
|
||||
<textarea placeholder="Context și detalii (partajat confidențial)" value={form.description}
|
||||
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
|
||||
rows={3} className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring resize-none sm:col-span-2" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => createMut.mutate()} disabled={!form.title || createMut.isPending}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
|
||||
{createMut.isPending ? 'Se salvează…' : 'Salvează'}
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="card p-3 text-center">
|
||||
<p className="text-xl font-bold text-signal-danger">{open.length}</p>
|
||||
<p className="text-[10px] text-ink-faint">deschise</p>
|
||||
</div>
|
||||
<div className="card p-3 text-center">
|
||||
<p className="text-xl font-bold text-signal-ok">{disputes.filter((d) => d.status === 'completed').length}</p>
|
||||
<p className="text-[10px] text-ink-faint">rezolvate</p>
|
||||
</div>
|
||||
<div className="card p-3 text-center">
|
||||
<p className="text-xl font-bold text-ink">{disputes.length}</p>
|
||||
<p className="text-[10px] text-ink-faint">total</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-ink-faint py-8">Se încarcă…</div>
|
||||
) : disputes.length === 0 ? (
|
||||
<div className="card p-8 text-center space-y-2">
|
||||
<p className="text-2xl">⚖️</p>
|
||||
<p className="text-sm text-signal-ok font-medium">✓ Niciun incident sau dispută înregistrată.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card divide-y divide-border/50">
|
||||
{disputes.map((d) => {
|
||||
const sl = RESOLUTION_STATUS[d.status] ?? { label: d.status, cls: 'bg-muted text-ink-faint' };
|
||||
return (
|
||||
<div key={d.id} className="p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-ink">{d.title}</p>
|
||||
{d.description && <p className="text-[10px] text-ink-faint mt-0.5 line-clamp-2">{d.description}</p>}
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[9px] font-bold shrink-0 ${sl.cls}`}>{sl.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] text-ink-faint">{new Date(d.createdAt).toLocaleDateString('ro-RO', { dateStyle: 'medium' })}</p>
|
||||
{d.status !== 'completed' && d.status !== 'cancelled' && (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => patchMut.mutate({ id: d.id, status: 'completed' })}
|
||||
className="text-[10px] rounded border px-2 py-0.5 text-signal-ok border-signal-ok/30 hover:bg-signal-ok/10">Rezolvat</button>
|
||||
<button onClick={() => patchMut.mutate({ id: d.id, status: 'in_progress' })}
|
||||
className="text-[10px] rounded border px-2 py-0.5 text-ink-faint hover:bg-muted/50">În lucru</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue