feat(CC-077): add Organizations (relationship view) with contact + contract linking
This commit is contained in:
parent
ea7ecb5cf9
commit
06a3c375df
1 changed files with 194 additions and 0 deletions
194
src/app/dashboard/relationships/organizations/page.tsx
Normal file
194
src/app/dashboard/relationships/organizations/page.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { apiFetch } from '../../../../lib/api';
|
||||
import { useSession } from '../../../../components/session-provider';
|
||||
|
||||
interface Organization {
|
||||
id: string; name: string; industry: string | null; country: string | null;
|
||||
employeeCount: number | null; tags: string[]; website: string | null;
|
||||
notes: string | null; createdAt: string;
|
||||
}
|
||||
interface Contact {
|
||||
id: string; fullName: string; role: string | null; tags: string[];
|
||||
}
|
||||
interface Contract {
|
||||
id: string; title: string; status: string; counterpartyName: string | null;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export default function OrganizationsRelPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const [search, setSearch] = useState('');
|
||||
const [industry, setIndustry] = useState('all');
|
||||
const [selected, setSelected] = useState<Organization | null>(null);
|
||||
|
||||
const { data: orgs = [], isLoading } = useQuery({
|
||||
queryKey: ['orgs-rel', tenantId],
|
||||
queryFn: () => apiFetch<Organization[]>('/v1/organizations?limit=500', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
const { data: contacts = [] } = useQuery({
|
||||
queryKey: ['contacts-rel2', tenantId],
|
||||
queryFn: () => apiFetch<Contact[]>('/v1/contacts?limit=500', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
const { data: contracts = [] } = useQuery({
|
||||
queryKey: ['contracts-rel', tenantId],
|
||||
queryFn: () => apiFetch<Contract[]>('/v1/contracts?limit=200', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 120_000,
|
||||
});
|
||||
|
||||
const industries = useMemo(() => {
|
||||
const set = new Set(orgs.map((o) => o.industry).filter(Boolean) as string[]);
|
||||
return ['all', ...Array.from(set).sort()];
|
||||
}, [orgs]);
|
||||
|
||||
const filtered = orgs.filter((o) => {
|
||||
if (industry !== 'all' && o.industry !== industry) return false;
|
||||
if (search && !o.name.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Contacts linked to selected org by tag (org name match)
|
||||
const orgContacts = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
const orgNameLower = selected.name.toLowerCase();
|
||||
return contacts.filter((c) =>
|
||||
c.tags.some((t) => t.toLowerCase() === orgNameLower || t.toLowerCase().includes(orgNameLower.split(' ')[0]))
|
||||
);
|
||||
}, [selected, contacts]);
|
||||
|
||||
const orgContracts = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
return contracts.filter((c) =>
|
||||
(c.counterpartyName ?? '').toLowerCase().includes(selected.name.toLowerCase().split(' ')[0])
|
||||
);
|
||||
}, [selected, contracts]);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Organizații</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{isLoading ? 'Se încarcă…' : `${orgs.length} organizații · vizualizare relații`}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/dashboard/crm" className="text-sm text-primary hover:underline">CRM complet →</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
|
||||
{/* List */}
|
||||
<div className="lg:col-span-2 space-y-3">
|
||||
<input placeholder="Caută organizație…" value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{industries.slice(0, 6).map((ind) => (
|
||||
<button key={ind} onClick={() => setIndustry(ind)}
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] border ${industry === ind ? 'bg-primary/10 border-primary text-ink' : 'bg-card border-border text-ink-faint'}`}>
|
||||
{ind === 'all' ? 'Toate' : ind}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-ink-faint py-6">Se încarcă…</div>
|
||||
) : (
|
||||
<div className="card divide-y divide-border/50 max-h-[500px] overflow-y-auto">
|
||||
{filtered.map((o) => (
|
||||
<button key={o.id} onClick={() => setSelected(o)}
|
||||
className={`w-full text-left p-3 transition-colors hover:bg-muted/50 ${selected?.id === o.id ? 'bg-primary/5' : ''}`}>
|
||||
<p className="text-sm font-medium text-ink">{o.name}</p>
|
||||
<div className="flex gap-2 mt-0.5">
|
||||
{o.industry && <span className="text-[10px] text-ink-faint">{o.industry}</span>}
|
||||
{o.country && <span className="text-[10px] text-ink-faint">📍 {o.country}</span>}
|
||||
{o.employeeCount && <span className="text-[10px] text-ink-faint">👥 {o.employeeCount}</span>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="p-6 text-center text-sm text-ink-faint">Nicio organizație.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{!selected ? (
|
||||
<div className="card p-8 text-center space-y-2">
|
||||
<p className="text-2xl">🏢</p>
|
||||
<p className="text-sm text-ink-faint">Selectează o organizație pentru a vedea relațiile.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card p-5 space-y-2">
|
||||
<h2 className="text-base font-semibold text-ink">{selected.name}</h2>
|
||||
<div className="flex flex-wrap gap-3 text-xs text-ink-faint">
|
||||
{selected.industry && <span>🏭 {selected.industry}</span>}
|
||||
{selected.country && <span>📍 {selected.country}</span>}
|
||||
{selected.employeeCount && <span>👥 {selected.employeeCount} angajați</span>}
|
||||
{selected.website && <a href={selected.website} target="_blank" rel="noopener" className="text-primary hover:underline">🌐 Website</a>}
|
||||
</div>
|
||||
{selected.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selected.tags.map((t) => (
|
||||
<span key={t} className="rounded-full bg-primary/10 px-2 py-0.5 text-[9px] text-ink">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selected.notes && <p className="text-xs text-ink-faint border-t border-border/50 pt-2">{selected.notes}</p>}
|
||||
</div>
|
||||
|
||||
{/* Linked contacts */}
|
||||
<div className="card p-4 space-y-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-ink-faint">
|
||||
Contacte legate ({orgContacts.length})
|
||||
</p>
|
||||
{orgContacts.length === 0 ? (
|
||||
<p className="text-xs text-ink-faint">Nicio legătură prin taguri. Tag-ează contacte cu numele organizației.</p>
|
||||
) : orgContacts.map((c) => (
|
||||
<div key={c.id} className="flex items-center gap-2 py-1 border-t border-border/30">
|
||||
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary">
|
||||
{c.fullName.split(' ').map(n => n[0]).slice(0,2).join('')}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-ink">{c.fullName}</p>
|
||||
{c.role && <p className="text-[10px] text-ink-faint">{c.role}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Linked contracts */}
|
||||
<div className="card p-4 space-y-2">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-ink-faint">
|
||||
Contracte ({orgContracts.length})
|
||||
</p>
|
||||
{orgContracts.length === 0 ? (
|
||||
<p className="text-xs text-ink-faint">Niciun contract direct. Lookup după counterpartyName.</p>
|
||||
) : orgContracts.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between py-1 border-t border-border/30">
|
||||
<p className="text-xs text-ink">{c.title}</p>
|
||||
<div className="text-right">
|
||||
<span className={`text-[9px] rounded-full px-1.5 py-0.5 ${c.status === 'active' ? 'bg-signal-ok/10 text-signal-ok' : 'bg-muted text-ink-faint'}`}>
|
||||
{c.status}
|
||||
</span>
|
||||
{c.expiresAt && <p className="text-[9px] text-ink-faint">{new Date(c.expiresAt).toLocaleDateString('ro-RO', { year: 'numeric', month: 'short' })}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue