Skip to content

Commit f152b80

Browse files
committed
feat(contacts): API CRM contatti CRUD completo + pagina /contacts con avatar, note espandibili, form e search
2 parents 6d69020 + 20e83f7 commit f152b80

2 files changed

Lines changed: 195 additions & 0 deletions

File tree

web/app/api/contacts/route.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* API Contacts — CRM contatti professionali CRUD
3+
*/
4+
import { NextResponse } from 'next/server';
5+
import fs from 'node:fs';
6+
import path from 'node:path';
7+
import os from 'node:os';
8+
import crypto from 'node:crypto';
9+
10+
export const dynamic = 'force-dynamic';
11+
12+
type Contact = { id: string; name: string; company: string; role: string; email: string; linkedin: string; notes: string; lastContact: number | null; createdAt: number };
13+
14+
const CONTACTS_PATH = path.join(os.homedir(), '.jht', 'contacts.json');
15+
16+
function load(): Contact[] {
17+
try { return JSON.parse(fs.readFileSync(CONTACTS_PATH, 'utf-8')); }
18+
catch { return generateSample(); }
19+
}
20+
21+
function save(data: Contact[]): void {
22+
const dir = path.dirname(CONTACTS_PATH);
23+
fs.mkdirSync(dir, { recursive: true });
24+
const tmp = CONTACTS_PATH + '.tmp';
25+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
26+
fs.renameSync(tmp, CONTACTS_PATH);
27+
}
28+
29+
function generateSample(): Contact[] {
30+
const now = Date.now(); const day = 86400000;
31+
return [
32+
{ id: 'ct-001', name: 'Marco Rossi', company: 'TechCorp', role: 'Engineering Manager', email: 'marco@techcorp.it', linkedin: '', notes: 'Incontrato a conferenza React. Interessato al mio profilo.', lastContact: now - 3 * day, createdAt: now - 30 * day },
33+
{ id: 'ct-002', name: 'Laura Bianchi', company: 'ScaleUp', role: 'CTO', email: 'laura@scaleup.io', linkedin: '', notes: 'Referral da amico comune. Colloquio in corso.', lastContact: now - day, createdAt: now - 15 * day },
34+
{ id: 'ct-003', name: 'Andrea Verdi', company: 'DataFlow', role: 'HR Lead', email: 'andrea@dataflow.com', linkedin: '', notes: 'Primo contatto via LinkedIn.', lastContact: now - 7 * day, createdAt: now - 20 * day },
35+
{ id: 'ct-004', name: 'Sara Neri', company: 'FinTech Co', role: 'Tech Recruiter', email: 'sara@fintech.co', linkedin: '', notes: 'Mi ha contattata per posizione Backend Lead.', lastContact: now - 2 * day, createdAt: now - 10 * day },
36+
{ id: 'ct-005', name: 'Luca Gialli', company: 'StartupXYZ', role: 'Co-founder', email: 'luca@startupxyz.com', linkedin: '', notes: 'Offerta ricevuta. Ottimo rapporto.', lastContact: now - 5 * day, createdAt: now - 60 * day },
37+
];
38+
}
39+
40+
export async function GET(req: Request) {
41+
const { searchParams } = new URL(req.url);
42+
const q = searchParams.get('q')?.toLowerCase();
43+
let contacts = load();
44+
if (q) contacts = contacts.filter(c => c.name.toLowerCase().includes(q) || c.company.toLowerCase().includes(q) || c.role.toLowerCase().includes(q));
45+
contacts.sort((a, b) => (b.lastContact ?? 0) - (a.lastContact ?? 0));
46+
return NextResponse.json({ contacts, total: contacts.length });
47+
}
48+
49+
export async function POST(req: Request) {
50+
try {
51+
const body = await req.json();
52+
const contact: Contact = { id: `ct-${crypto.randomBytes(4).toString('hex')}`, name: body.name ?? '', company: body.company ?? '', role: body.role ?? '', email: body.email ?? '', linkedin: body.linkedin ?? '', notes: body.notes ?? '', lastContact: null, createdAt: Date.now() };
53+
if (!contact.name) return NextResponse.json({ error: 'Nome richiesto' }, { status: 400 });
54+
const contacts = load(); contacts.push(contact); save(contacts);
55+
return NextResponse.json({ ok: true, contact });
56+
} catch (err) { return NextResponse.json({ error: String(err) }, { status: 500 }); }
57+
}
58+
59+
export async function PUT(req: Request) {
60+
try {
61+
const body = await req.json();
62+
const { id, ...updates } = body as { id: string; [k: string]: unknown };
63+
if (!id) return NextResponse.json({ error: 'id richiesto' }, { status: 400 });
64+
const contacts = load(); const ct = contacts.find(c => c.id === id);
65+
if (!ct) return NextResponse.json({ error: 'Contatto non trovato' }, { status: 404 });
66+
Object.assign(ct, updates);
67+
save(contacts);
68+
return NextResponse.json({ ok: true, contact: ct });
69+
} catch (err) { return NextResponse.json({ error: String(err) }, { status: 500 }); }
70+
}
71+
72+
export async function DELETE(req: Request) {
73+
try {
74+
const { id } = await req.json() as { id: string };
75+
if (!id) return NextResponse.json({ error: 'id richiesto' }, { status: 400 });
76+
const contacts = load(); const idx = contacts.findIndex(c => c.id === id);
77+
if (idx === -1) return NextResponse.json({ error: 'Contatto non trovato' }, { status: 404 });
78+
contacts.splice(idx, 1); save(contacts);
79+
return NextResponse.json({ ok: true });
80+
} catch (err) { return NextResponse.json({ error: String(err) }, { status: 500 }); }
81+
}

web/app/contacts/page.tsx

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
'use client'
2+
3+
import Link from 'next/link'
4+
import { useEffect, useState, useCallback } from 'react'
5+
6+
type Contact = { id: string; name: string; company: string; role: string; email: string; linkedin: string; notes: string; lastContact: number | null; createdAt: number }
7+
8+
function timeAgo(ts: number | null): string {
9+
if (!ts) return 'mai';
10+
const m = Math.floor((Date.now() - ts) / 60000);
11+
if (m < 60) return `${m}m fa`; if (m < 1440) return `${Math.floor(m / 60)}h fa`; return `${Math.floor(m / 1440)}g fa`;
12+
}
13+
14+
function ContactRow({ c, expanded, onToggle, onDelete }: { c: Contact; expanded: boolean; onToggle: () => void; onDelete: (id: string) => void }) {
15+
return (
16+
<div className="border-b border-[var(--color-border)]">
17+
<div className="flex items-center gap-3 px-5 py-3 hover:bg-[var(--color-row)] transition-colors cursor-pointer" onClick={onToggle}>
18+
<div className="w-8 h-8 rounded-full flex items-center justify-center text-[10px] font-bold flex-shrink-0" style={{ background: 'var(--color-row)', color: 'var(--color-muted)', border: '1px solid var(--color-border)' }}>
19+
{c.name.split(' ').map(n => n[0]).join('').slice(0, 2)}
20+
</div>
21+
<div className="flex-1 min-w-0">
22+
<p className="text-[11px] text-[var(--color-bright)] font-medium truncate">{c.name}</p>
23+
<p className="text-[9px] text-[var(--color-dim)]">{c.role} · {c.company}</p>
24+
</div>
25+
{c.email && <span className="text-[9px] text-[var(--color-dim)] truncate max-w-[150px]">{c.email}</span>}
26+
<span className="text-[9px] text-[var(--color-dim)] w-14 text-right">{timeAgo(c.lastContact)}</span>
27+
</div>
28+
{expanded && (
29+
<div className="px-5 pb-3 pl-16">
30+
{c.notes && <p className="text-[10px] text-[var(--color-muted)] mb-2">{c.notes}</p>}
31+
<div className="flex gap-3 items-center">
32+
{c.email && <span className="text-[9px] font-mono text-[var(--color-dim)]">{c.email}</span>}
33+
{c.linkedin && <span className="text-[9px] font-mono text-[var(--color-dim)]">{c.linkedin}</span>}
34+
<span className="text-[9px] text-[var(--color-dim)]">Aggiunto: {timeAgo(c.createdAt)}</span>
35+
<button onClick={e => { e.stopPropagation(); onDelete(c.id); }} className="text-[9px] font-bold cursor-pointer ml-auto" style={{ color: 'var(--color-red)' }}>elimina</button>
36+
</div>
37+
</div>
38+
)}
39+
</div>
40+
)
41+
}
42+
43+
export default function ContactsPage() {
44+
const [contacts, setContacts] = useState<Contact[]>([])
45+
const [total, setTotal] = useState(0)
46+
const [search, setSearch] = useState('')
47+
const [expandedId, setExpandedId] = useState<string | null>(null)
48+
const [adding, setAdding] = useState(false)
49+
const [newName, setNewName] = useState('')
50+
const [newCompany, setNewCompany] = useState('')
51+
const [newRole, setNewRole] = useState('')
52+
const [newEmail, setNewEmail] = useState('')
53+
54+
const fetchData = useCallback(async () => {
55+
const params = search ? `?q=${encodeURIComponent(search)}` : '';
56+
const res = await fetch(`/api/contacts${params}`).catch(() => null);
57+
if (!res?.ok) return;
58+
const data = await res.json();
59+
setContacts(data.contacts ?? []); setTotal(data.total ?? 0);
60+
}, [search])
61+
62+
useEffect(() => { fetchData() }, [fetchData])
63+
64+
const addContact = async () => {
65+
if (!newName.trim()) return;
66+
await fetch('/api/contacts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName, company: newCompany, role: newRole, email: newEmail }) }).catch(() => null);
67+
setNewName(''); setNewCompany(''); setNewRole(''); setNewEmail(''); setAdding(false); fetchData();
68+
}
69+
70+
const deleteContact = async (id: string) => {
71+
await fetch('/api/contacts', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) }).catch(() => null);
72+
fetchData();
73+
}
74+
75+
const inputStyle = { background: 'var(--color-deep)', color: 'var(--color-muted)', border: '1px solid var(--color-border)' } as const;
76+
77+
return (
78+
<div style={{ animation: 'fade-in 0.35s ease both' }}>
79+
<div className="mb-8 pb-6 border-b border-[var(--color-border)]">
80+
<div className="flex items-center gap-2 mb-1">
81+
<Link href="/dashboard" className="text-[10px] text-[var(--color-dim)] hover:text-[var(--color-muted)] no-underline transition-colors">Dashboard</Link>
82+
<span className="text-[var(--color-border)]">/</span>
83+
<span className="text-[10px] text-[var(--color-muted)]">Contatti</span>
84+
</div>
85+
<div className="flex items-center justify-between mt-3">
86+
<div><h1 className="text-2xl font-bold tracking-tight text-[var(--color-white)]">Contatti</h1>
87+
<p className="text-[var(--color-muted)] text-[11px] mt-1">{total} contatti professionali</p></div>
88+
<button onClick={() => setAdding(!adding)} className="px-3 py-1.5 rounded text-[10px] font-bold cursor-pointer" style={{ background: 'var(--color-green)', color: '#000' }}>{adding ? 'Annulla' : '+ Nuovo'}</button>
89+
</div>
90+
</div>
91+
92+
{adding && (
93+
<div className="mb-4 p-4 rounded-lg flex gap-2 items-end" style={{ background: 'var(--color-row)', border: '1px solid var(--color-border)' }}>
94+
<div className="flex flex-col gap-0.5"><label className="text-[8px] font-bold tracking-widest text-[var(--color-dim)]">NOME</label><input value={newName} onChange={e => setNewName(e.target.value)} className="text-[10px] px-2 py-1.5 rounded w-32" style={inputStyle} /></div>
95+
<div className="flex flex-col gap-0.5"><label className="text-[8px] font-bold tracking-widest text-[var(--color-dim)]">AZIENDA</label><input value={newCompany} onChange={e => setNewCompany(e.target.value)} className="text-[10px] px-2 py-1.5 rounded w-28" style={inputStyle} /></div>
96+
<div className="flex flex-col gap-0.5"><label className="text-[8px] font-bold tracking-widest text-[var(--color-dim)]">RUOLO</label><input value={newRole} onChange={e => setNewRole(e.target.value)} className="text-[10px] px-2 py-1.5 rounded w-28" style={inputStyle} /></div>
97+
<div className="flex flex-col gap-0.5"><label className="text-[8px] font-bold tracking-widest text-[var(--color-dim)]">EMAIL</label><input value={newEmail} onChange={e => setNewEmail(e.target.value)} className="text-[10px] px-2 py-1.5 rounded w-36" style={inputStyle} /></div>
98+
<button onClick={addContact} className="px-3 py-1.5 rounded text-[10px] font-bold cursor-pointer" style={{ background: 'var(--color-green)', color: '#000' }}>Aggiungi</button>
99+
</div>
100+
)}
101+
102+
<div className="mb-4">
103+
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Cerca nome, azienda, ruolo..."
104+
className="text-[10px] px-3 py-1.5 rounded w-56" style={inputStyle} />
105+
</div>
106+
107+
<div className="border border-[var(--color-border)] rounded-lg overflow-hidden bg-[var(--color-panel)]">
108+
{contacts.length === 0
109+
? <div className="py-12 text-center"><p className="text-[var(--color-dim)] text-[12px]">Nessun contatto trovato.</p></div>
110+
: contacts.map(c => <ContactRow key={c.id} c={c} expanded={expandedId === c.id} onToggle={() => setExpandedId(expandedId === c.id ? null : c.id)} onDelete={deleteContact} />)}
111+
</div>
112+
</div>
113+
)
114+
}

0 commit comments

Comments
 (0)