feat(CC-085): add Introductions & Networking page (task-based intro requests with status)
This commit is contained in:
parent
14416eb20e
commit
c0c2c15919
1 changed files with 156 additions and 0 deletions
156
src/app/dashboard/introductions/page.tsx
Normal file
156
src/app/dashboard/introductions/page.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, 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; firstName: string; lastName: string | null; role: string | null; organization: string | null; tags: string[]; email: string | null; createdAt: string; }
|
||||
interface Task { id: string; title: string; status: string; tags: string[]; createdAt: string; }
|
||||
|
||||
const INTRO_TAGS = ['introduction', 'intro', 'introducere', 'conectare', 'networking'];
|
||||
|
||||
export default function IntroductionsPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [showRequest, setShowRequest] = useState(false);
|
||||
const [form, setForm] = useState({ from: '', to: '', reason: '', priority: 'normal' });
|
||||
|
||||
const { data: contacts = [] } = useQuery({ queryKey: ['intro-contacts', tenantId], queryFn: () => apiFetch<Contact[]>('/v1/contacts?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
|
||||
const { data: tasks = [] } = useQuery({ queryKey: ['intro-tasks', tenantId], queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
|
||||
|
||||
const introTasks = useMemo(() =>
|
||||
tasks.filter((t) => t.tags.some((tag) => INTRO_TAGS.includes(tag.toLowerCase())))
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
|
||||
[tasks]);
|
||||
|
||||
const introContacts = useMemo(() =>
|
||||
contacts.filter((c) => c.tags.some((t) => INTRO_TAGS.includes(t.toLowerCase()))),
|
||||
[contacts]);
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => apiFetch('/v1/tasks', { tenantId, method: 'POST', body: {
|
||||
title: `Introducere: ${form.from} ↔ ${form.to}`,
|
||||
description: form.reason || undefined,
|
||||
priority: form.priority,
|
||||
tags: ['introduction', 'intro'],
|
||||
status: 'todo',
|
||||
}}),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['intro-tasks', tenantId] }); setShowRequest(false); setForm({ from: '', to: '', reason: '', priority: 'normal' }); },
|
||||
});
|
||||
|
||||
const patchMut = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
apiFetch(`/v1/tasks/${id}`, { tenantId, method: 'PATCH', body: { status } }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['intro-tasks', tenantId] }),
|
||||
});
|
||||
|
||||
const statusCls: Record<string, string> = {
|
||||
todo: 'bg-muted text-ink-faint',
|
||||
in_progress: 'bg-primary/10 text-primary',
|
||||
completed: 'bg-signal-ok/10 text-signal-ok',
|
||||
cancelled: 'bg-signal-danger/10 text-signal-danger',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6 p-6">
|
||||
<div className="flex items-start justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Introduceri & Networking</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{introTasks.length} cereri de introducere urmărite · {introContacts.length} contacte cu tag intro
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowRequest(true)}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
|
||||
+ Cerere introducere
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showRequest && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<p className="text-sm font-semibold text-ink">Cerere introducere nouă</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] text-ink-faint">Cine te-ar putea introduce</label>
|
||||
<input placeholder="ex: Maria Ionescu (cunoaștere comună)" value={form.from}
|
||||
onChange={(e) => setForm((p) => ({ ...p, from: e.target.value }))}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] text-ink-faint">Cu cine vrei să fii introdus</label>
|
||||
<input placeholder="ex: Alexandru Popa, CTO Startup X" value={form.to}
|
||||
onChange={(e) => setForm((p) => ({ ...p, to: e.target.value }))}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
</div>
|
||||
<input placeholder="Motivul / contextul introducerii" value={form.reason}
|
||||
onChange={(e) => setForm((p) => ({ ...p, reason: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring sm:col-span-2" />
|
||||
<select value={form.priority} onChange={(e) => setForm((p) => ({ ...p, priority: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<option value="low">Prioritate scăzută</option>
|
||||
<option value="normal">Prioritate normală</option>
|
||||
<option value="high">Prioritate înaltă</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => createMut.mutate()} disabled={!form.from || !form.to || createMut.isPending}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
|
||||
{createMut.isPending ? 'Se salvează…' : 'Salvează'}
|
||||
</button>
|
||||
<button onClick={() => setShowRequest(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="card p-3 text-center">
|
||||
<p className="text-xl font-bold text-ink">{introTasks.filter((t) => t.status === 'todo' || t.status === 'in_progress').length}</p>
|
||||
<p className="text-[10px] text-ink-faint">în așteptare</p>
|
||||
</div>
|
||||
<div className="card p-3 text-center">
|
||||
<p className="text-xl font-bold text-signal-ok">{introTasks.filter((t) => t.status === 'completed').length}</p>
|
||||
<p className="text-[10px] text-ink-faint">realizate</p>
|
||||
</div>
|
||||
<div className="card p-3 text-center">
|
||||
<p className="text-xl font-bold text-ink">{introContacts.length}</p>
|
||||
<p className="text-[10px] text-ink-faint">contacte intro</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{introTasks.length === 0 ? (
|
||||
<div className="card p-8 text-center space-y-2">
|
||||
<p className="text-2xl">🤝</p>
|
||||
<p className="text-sm text-ink-faint">Nicio cerere de introducere. Adaugă prima.</p>
|
||||
<p className="text-xs text-ink-faint">Task-urile cu tag <code className="bg-muted px-1 rounded">introduction</code> sau <code className="bg-muted px-1 rounded">intro</code> apar automat.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card divide-y divide-border/50">
|
||||
{introTasks.map((t) => (
|
||||
<div key={t.id} className="p-4 flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-ink">{t.title}</p>
|
||||
<p className="text-[10px] text-ink-faint">
|
||||
{new Date(t.createdAt).toLocaleDateString('ro-RO', { dateStyle: 'medium' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[9px] font-bold ${statusCls[t.status] ?? 'bg-muted text-ink-faint'}`}>
|
||||
{t.status}
|
||||
</span>
|
||||
{t.status !== 'completed' && t.status !== 'cancelled' && (
|
||||
<button onClick={() => patchMut.mutate({ id: t.id, status: 'completed' })}
|
||||
className="text-[10px] rounded border px-2 py-0.5 text-signal-ok border-signal-ok/30 hover:bg-signal-ok/10">
|
||||
✓ Realizat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue