feat(CC-086): add Professional Network page (contacts with pro tags, search, tag filter, stats)
This commit is contained in:
parent
ad43e8d831
commit
21c5aa05ec
1 changed files with 155 additions and 0 deletions
155
src/app/dashboard/professional-network/page.tsx
Normal file
155
src/app/dashboard/professional-network/page.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
'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 Contact { id: string; firstName: string; lastName: string | null; role: string | null; organization: string | null; tags: string[]; email: string | null; linkedinUrl: string | null; country: string | null; createdAt: string; }
|
||||
|
||||
const PRO_TAGS = ['mentor', 'investor', 'investitor', 'partner', 'partener', 'advisor', 'board', 'client', 'collaborator', 'coleg', 'colleague', 'recruiter', 'founder', 'ceo', 'cto'];
|
||||
const INDUSTRIES = ['Tech', 'Finance', 'Legal', 'Healthcare', 'Education', 'Consulting', 'AI/ML', 'SaaS', 'Altele'];
|
||||
|
||||
export default function ProfessionalNetworkPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const [search, setSearch] = useState('');
|
||||
const [tagFilter, setTagFilter] = useState('');
|
||||
|
||||
const { data: contacts = [], isLoading } = useQuery({
|
||||
queryKey: ['pro-network', tenantId],
|
||||
queryFn: () => apiFetch<Contact[]>('/v1/contacts?limit=500', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||||
});
|
||||
|
||||
const network = useMemo(() =>
|
||||
contacts.filter((c) => c.tags.some((t) => PRO_TAGS.includes(t.toLowerCase()))),
|
||||
[contacts]);
|
||||
|
||||
const tagCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const c of network) {
|
||||
for (const t of c.tags) {
|
||||
if (PRO_TAGS.includes(t.toLowerCase())) {
|
||||
counts[t.toLowerCase()] = (counts[t.toLowerCase()] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
||||
}, [network]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = network;
|
||||
if (tagFilter) list = list.filter((c) => c.tags.some((t) => t.toLowerCase() === tagFilter));
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
list = list.filter((c) =>
|
||||
`${c.firstName} ${c.lastName ?? ''} ${c.organization ?? ''} ${c.role ?? ''}`.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}, [network, tagFilter, search]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const countries = new Set(network.map((c) => c.country).filter(Boolean)).size;
|
||||
const orgs = new Set(network.map((c) => c.organization).filter(Boolean)).size;
|
||||
const withLinkedIn = network.filter((c) => c.linkedinUrl).length;
|
||||
return { countries, orgs, withLinkedIn };
|
||||
}, [network]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Rețea Profesională</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{isLoading ? 'Se încarcă…' : `${network.length} contacte profesionale cheie din ${contacts.length} total`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="card p-4 text-center">
|
||||
<p className="text-2xl font-bold text-ink">{network.length}</p>
|
||||
<p className="text-[10px] text-ink-faint">rețea profesională</p>
|
||||
</div>
|
||||
<div className="card p-4 text-center">
|
||||
<p className="text-2xl font-bold text-ink">{stats.orgs}</p>
|
||||
<p className="text-[10px] text-ink-faint">organizații</p>
|
||||
</div>
|
||||
<div className="card p-4 text-center">
|
||||
<p className="text-2xl font-bold text-ink">{stats.countries}</p>
|
||||
<p className="text-[10px] text-ink-faint">țări</p>
|
||||
</div>
|
||||
<div className="card p-4 text-center">
|
||||
<p className="text-2xl font-bold text-ink">{stats.withLinkedIn}</p>
|
||||
<p className="text-[10px] text-ink-faint">LinkedIn</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag breakdown */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => setTagFilter('')}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium border transition-colors ${tagFilter === '' ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
|
||||
Toate ({network.length})
|
||||
</button>
|
||||
{tagCounts.map(([tag, count]) => (
|
||||
<button key={tag} onClick={() => setTagFilter(tagFilter === tag ? '' : tag)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium border capitalize transition-colors ${tagFilter === tag ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
|
||||
{tag} ({count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<input value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Caută după nume, companie, rol…"
|
||||
className="w-full rounded-lg border bg-background px-4 py-2.5 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
|
||||
{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">
|
||||
{network.length === 0
|
||||
? 'Niciun contact profesional. Adaugă contacte cu tag mentor/investor/partner din Contacte.'
|
||||
: 'Niciun rezultat pentru filtrul curent.'}
|
||||
</p>
|
||||
{network.length === 0 && (
|
||||
<Link href="/dashboard/relationships/contacts" className="text-sm text-primary hover:underline">Gestionează contacte →</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{filtered.map((c) => {
|
||||
const proTags = c.tags.filter((t) => PRO_TAGS.includes(t.toLowerCase()));
|
||||
return (
|
||||
<div key={c.id} className="card p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-ink truncate">{c.firstName} {c.lastName ?? ''}</p>
|
||||
{c.role && <p className="text-[10px] text-ink-faint truncate">{c.role}</p>}
|
||||
{c.organization && <p className="text-[10px] text-primary truncate">{c.organization}</p>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 shrink-0 justify-end">
|
||||
{proTags.slice(0, 2).map((t) => (
|
||||
<span key={t} className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[8px] font-bold text-primary capitalize">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-ink-faint">
|
||||
{c.email && <a href={`mailto:${c.email}`} className="text-primary hover:underline truncate">{c.email}</a>}
|
||||
{c.linkedinUrl && <a href={c.linkedinUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline shrink-0">LinkedIn →</a>}
|
||||
{c.country && !c.email && !c.linkedinUrl && <span>{c.country}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link href="/dashboard/relationships" className="text-xs text-primary hover:underline">← Toate relațiile</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue