feat(CC-063): add CRM & Contacts page (list/create, consent status, org link)

This commit is contained in:
admin-valentin 2026-08-01 21:20:56 +00:00
parent 2a265567e2
commit 365b17d71b

View file

@ -0,0 +1,188 @@
'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 Contact {
id: string; fullName: string; email: string | null; phone: string | null;
role: string | null; organizationId: string | null; source: string | null;
consentStatus: string; tags: string[]; notes: string | null;
createdAt: string;
}
interface Organization { id: string; name: string; }
const CONSENT_META: Record<string, { label: string; cls: string }> = {
granted: { label: 'Consimțit', cls: 'bg-signal-ok/10 text-signal-ok' },
revoked: { label: 'Revocat', cls: 'bg-signal-danger/10 text-signal-danger' },
unknown: { label: 'Necunoscut', cls: 'bg-muted text-ink-faint' },
};
export default function CrmPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [search, setSearch] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({ fullName: '', email: '', phone: '', role: '', notes: '' });
const { data: contacts = [], isLoading } = useQuery({
queryKey: ['contacts', tenantId],
queryFn: () => apiFetch<Contact[]>('/v1/contacts', { tenantId }),
enabled: Boolean(tenantId),
staleTime: 60_000,
});
const { data: orgs = [] } = useQuery({
queryKey: ['orgs', tenantId],
queryFn: () => apiFetch<Organization[]>('/v1/organizations', { tenantId }),
enabled: Boolean(tenantId),
staleTime: 300_000,
});
const orgMap = Object.fromEntries(orgs.map((o) => [o.id, o.name]));
const { mutate: createContact, isPending } = useMutation({
mutationFn: () => apiFetch<Contact>('/v1/contacts', { method: 'POST', body: form, tenantId }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['contacts', tenantId] });
setShowCreate(false);
setForm({ fullName: '', email: '', phone: '', role: '', notes: '' });
},
});
const filtered = contacts.filter((c) =>
!search ||
c.fullName.toLowerCase().includes(search.toLowerCase()) ||
(c.email ?? '').toLowerCase().includes(search.toLowerCase()) ||
(c.role ?? '').toLowerCase().includes(search.toLowerCase())
);
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">CRM & Contacte</h1>
<p className="text-sm text-ink-faint mt-1">{contacts.length} contacte înregistrate</p>
</div>
<button onClick={() => setShowCreate(true)} className="btn btn-primary text-xs px-4 py-2">
+ Contact nou
</button>
</div>
{/* Create form */}
{showCreate && (
<div className="card p-5 space-y-4 border-primary/30">
<h2 className="text-sm font-semibold text-ink">Contact nou</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{[
{ key: 'fullName', label: 'Nume complet *', placeholder: 'Ion Popescu' },
{ key: 'email', label: 'Email', placeholder: 'ion@example.com' },
{ key: 'phone', label: 'Telefon', placeholder: '+40 7xx xxx xxx' },
{ key: 'role', label: 'Rol / Funcție', placeholder: 'Director General' },
].map(({ key, label, placeholder }) => (
<div key={key}>
<label className="text-xs text-ink-faint block mb-1">{label}</label>
<input
value={(form as Record<string, string>)[key]}
onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.value }))}
placeholder={placeholder}
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>
<label className="text-xs text-ink-faint block mb-1">Notițe</label>
<textarea
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={2}
className="w-full rounded-lg border bg-card px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div className="flex gap-3">
<button
onClick={() => createContact()}
disabled={isPending || !form.fullName.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>
)}
{/* Search */}
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Caută după nume, email, rol…"
className="w-full max-w-sm rounded-lg border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{/* Contact list */}
{isLoading ? (
<div className="card p-8 text-center text-sm text-ink-faint">Se încarcă</div>
) : filtered.length === 0 ? (
<div className="card p-12 text-center space-y-2">
<p className="text-3xl">👤</p>
<p className="text-sm text-ink-faint">
{search ? 'Niciun contact nu corespunde căutării.' : 'Niciun contact înregistrat.'}
</p>
<button onClick={() => setShowCreate(true)} className="text-xs text-bronze-deep hover:underline">
Adaugă primul contact
</button>
</div>
) : (
<div className="divide-y divide-border/50 rounded-xl border overflow-hidden">
{filtered.map((c) => {
const consent = CONSENT_META[c.consentStatus] ?? CONSENT_META.unknown;
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="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<span className="text-sm font-semibold text-primary">
{c.fullName.charAt(0).toUpperCase()}
</span>
</div>
<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.fullName}</p>
{c.role && <span className="text-xs text-ink-faint">{c.role}</span>}
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${consent.cls}`}>
{consent.label}
</span>
</div>
<div className="flex items-center gap-3 mt-0.5">
{c.email && <span className="text-[11px] text-ink-faint">{c.email}</span>}
{c.phone && <span className="text-[11px] text-ink-faint">{c.phone}</span>}
{c.organizationId && (
<span className="text-[11px] text-ink-faint">
@ {orgMap[c.organizationId] ?? '—'}
</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>
)}
{/* GDPR notice */}
<div className="rounded-lg border border-border/50 bg-muted/30 p-3">
<p className="text-xs text-ink-faint">
<strong className="text-ink">Protecție date:</strong> Stochează contactele cu
consimțământ explicit. Contactele fără consimțământ sunt marcate ca &quot;Necunoscut&quot;
și nu vor fi procesate pentru AI sau export.
</p>
</div>
</div>
);
}