feat(CC-075): add People page (privacy-first contacts with GDPR consent management)
This commit is contained in:
parent
18013ed6d7
commit
cad9026312
1 changed files with 210 additions and 0 deletions
210
src/app/dashboard/people/page.tsx
Normal file
210
src/app/dashboard/people/page.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { apiFetch } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
interface Contact {
|
||||
id: string; fullName: string; email: string | null; phone: string | null;
|
||||
role: string | null; tags: string[]; consentStatus: string;
|
||||
notes: string | null; createdAt: string; updatedAt: string;
|
||||
}
|
||||
|
||||
const CONSENT_CONFIG: Record<string, { label: string; icon: string; cls: string }> = {
|
||||
granted: { label: 'GDPR granted', icon: '✅', cls: 'bg-signal-ok/10 text-signal-ok border-signal-ok/30' },
|
||||
revoked: { label: 'GDPR revoked', icon: '❌', cls: 'bg-signal-danger/10 text-signal-danger border-signal-danger/30' },
|
||||
unknown: { label: 'Necunoscut', icon: '❓', cls: 'bg-muted text-ink-faint border-border' },
|
||||
pending: { label: 'În așteptare', icon: '⏳', cls: 'bg-warn/10 text-warn border-warn/30' },
|
||||
};
|
||||
|
||||
export default function PeoplePage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [consent, setConsent] = useState('all');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({ fullName: '', email: '', phone: '', role: '', tags: '', notes: '', consentStatus: 'unknown' });
|
||||
const [selected, setSelected] = useState<Contact | null>(null);
|
||||
|
||||
const { data: contacts = [], isLoading } = useQuery({
|
||||
queryKey: ['people', tenantId],
|
||||
queryFn: () => apiFetch<Contact[]>('/v1/contacts?limit=500', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 30_000,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: Record<string, string | string[]>) =>
|
||||
apiFetch<Contact>('/v1/contacts', { tenantId, method: 'POST', body }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['people', tenantId] });
|
||||
setShowCreate(false);
|
||||
setForm({ fullName: '', email: '', phone: '', role: '', tags: '', notes: '', consentStatus: 'unknown' });
|
||||
},
|
||||
});
|
||||
|
||||
const updateConsentMut = useMutation({
|
||||
mutationFn: ({ id, consentStatus }: { id: string; consentStatus: string }) =>
|
||||
apiFetch<Contact>(`/v1/contacts/${id}`, { tenantId, method: 'PATCH', body: { consentStatus } }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['people', tenantId] });
|
||||
setSelected(null);
|
||||
},
|
||||
});
|
||||
|
||||
const filtered = contacts.filter((c) => {
|
||||
if (consent !== 'all' && c.consentStatus !== consent) return false;
|
||||
if (search && !c.fullName.toLowerCase().includes(search.toLowerCase())
|
||||
&& !(c.email ?? '').toLowerCase().includes(search.toLowerCase())
|
||||
&& !(c.role ?? '').toLowerCase().includes(search.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const counts: Record<string, number> = { all: contacts.length };
|
||||
for (const c of contacts) counts[c.consentStatus] = (counts[c.consentStatus] ?? 0) + 1;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl 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">Persoane</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{isLoading ? 'Se încarcă…' : `${contacts.length} persoane · ${counts['granted'] ?? 0} cu consimțământ GDPR`}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(!showCreate)}
|
||||
className="btn btn-primary px-4 py-2 text-sm rounded-lg">
|
||||
+ Persoană nouă
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* GDPR warning for revoked contacts */}
|
||||
{(counts['revoked'] ?? 0) > 0 && (
|
||||
<div className="card p-4 border-signal-danger/30 bg-signal-danger/5 flex items-center gap-3">
|
||||
<span className="text-xl">⚠️</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-signal-danger">
|
||||
{counts['revoked']} persoane cu GDPR revocat
|
||||
</p>
|
||||
<p className="text-xs text-ink-faint">Datele acestor persoane ar trebui șterse conform politicii de retenție.</p>
|
||||
</div>
|
||||
<Link href="/dashboard/privacy/consents" className="ml-auto text-xs text-primary hover:underline shrink-0">
|
||||
Gestionează →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Consent filter */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => setConsent('all')}
|
||||
className={`rounded-full px-3 py-1 text-xs border ${consent === 'all' ? 'bg-primary/10 border-primary text-ink font-medium' : 'bg-card border-border text-ink-faint'}`}>
|
||||
Toate ({contacts.length})
|
||||
</button>
|
||||
{Object.entries(CONSENT_CONFIG).map(([key, cfg]) => (
|
||||
<button key={key} onClick={() => setConsent(key)}
|
||||
className={`rounded-full px-3 py-1 text-xs border ${consent === key ? 'bg-primary/10 border-primary text-ink font-medium' : 'bg-card border-border text-ink-faint'}`}>
|
||||
{cfg.icon} {cfg.label} ({counts[key] ?? 0})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<input placeholder="Caută după nume, email sau rol…" value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-lg border bg-card px-4 py-2.5 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-ink">Persoană nouă</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input placeholder="Nume complet *" value={form.fullName}
|
||||
onChange={(e) => setForm({ ...form, fullName: e.target.value })}
|
||||
className="col-span-2 rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="Email" value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="Telefon" value={form.phone}
|
||||
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="Rol / funcție" value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value })}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<select value={form.consentStatus} onChange={(e) => setForm({ ...form, consentStatus: e.target.value })}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
{Object.entries(CONSENT_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.icon} {v.label}</option>)}
|
||||
</select>
|
||||
<input placeholder="Taguri (virgulă)" value={form.tags}
|
||||
onChange={(e) => setForm({ ...form, tags: e.target.value })}
|
||||
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<input placeholder="Note (opțional)" value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
className="col-span-2 rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => createMut.mutate({ fullName: form.fullName, email: form.email || undefined, phone: form.phone || undefined, role: form.role || undefined, notes: form.notes || undefined, consentStatus: form.consentStatus, tags: form.tags.split(',').map(t => t.trim()).filter(Boolean) } as Record<string,string|string[]>)}
|
||||
disabled={!form.fullName || createMut.isPending}
|
||||
className="btn btn-primary px-4 py-1.5 text-sm rounded-lg disabled:opacity-50">
|
||||
{createMut.isPending ? 'Se creează…' : 'Salvează'}
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="text-sm text-ink-faint hover:text-ink px-2">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* People list */}
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-ink-faint py-8">Se încarcă…</div>
|
||||
) : filtered.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 persoană{search ? ' pentru această căutare' : ''}.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card divide-y divide-border/50">
|
||||
{filtered.map((c) => {
|
||||
const cfg = CONSENT_CONFIG[c.consentStatus] ?? CONSENT_CONFIG.unknown;
|
||||
return (
|
||||
<div key={c.id} className="flex items-center gap-4 p-4">
|
||||
{/* Avatar */}
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-sm font-bold text-primary shrink-0">
|
||||
{c.fullName.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-ink">{c.fullName}</p>
|
||||
<div className="flex items-center gap-2 flex-wrap mt-0.5">
|
||||
{c.role && <span className="text-[10px] text-ink-faint">{c.role}</span>}
|
||||
{c.email && <span className="text-[10px] text-ink-faint">{c.email}</span>}
|
||||
{c.tags.slice(0, 3).map((t) => (
|
||||
<span key={t} className="rounded-full bg-primary/8 px-1.5 py-0.5 text-[9px] text-ink">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`rounded-full border px-2 py-0.5 text-[9px] font-medium ${cfg.cls}`}>
|
||||
{cfg.icon} {c.consentStatus}
|
||||
</span>
|
||||
{/* Quick consent toggle */}
|
||||
{c.consentStatus !== 'granted' && (
|
||||
<button onClick={() => updateConsentMut.mutate({ id: c.id, consentStatus: 'granted' })}
|
||||
className="text-[10px] text-signal-ok hover:underline">
|
||||
+ GDPR
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Link href={`/dashboard/intelligence/people`}
|
||||
className="text-xs text-primary hover:underline shrink-0">
|
||||
Profil →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue