feat(CC-063): add CRM & Contacts page (list/create, consent status, org link)
This commit is contained in:
parent
2a265567e2
commit
365b17d71b
1 changed files with 188 additions and 0 deletions
188
src/app/dashboard/crm/page.tsx
Normal file
188
src/app/dashboard/crm/page.tsx
Normal 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 "Necunoscut"
|
||||
și nu vor fi procesate pentru AI sau export.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue