188 lines
8.1 KiB
TypeScript
188 lines
8.1 KiB
TypeScript
'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 "Necunoscut"
|
|
și nu vor fi procesate pentru AI sau export.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|