feat(CC-086): add Communities page (observations with community subjectType + engagement levels)
This commit is contained in:
parent
a0f60e9495
commit
ad43e8d831
1 changed files with 139 additions and 0 deletions
139
src/app/dashboard/communities/page.tsx
Normal file
139
src/app/dashboard/communities/page.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
'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; tags: string[]; organization: string | null; createdAt: string; }
|
||||
interface Observation { id: string; metric: string; value: string; source: string | null; subjectType: string; createdAt: string; }
|
||||
|
||||
const COMMUNITY_TAGS = ['community', 'comunitate', 'group', 'grup', 'association', 'asociatie', 'club', 'network', 'forum', 'slack', 'discord'];
|
||||
const ENGAGEMENT_LEVELS = [
|
||||
{ level: 'core', label: 'Core member', icon: '⭐', desc: 'Contributor activ' },
|
||||
{ level: 'active', label: 'Activ', icon: '🟢', desc: 'Participare regulată' },
|
||||
{ level: 'observer', label: 'Observer', icon: '👀', desc: 'Urmăresc activitatea' },
|
||||
];
|
||||
|
||||
export default function CommunitiesPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', url: '', type: 'community', level: 'active', since: '' });
|
||||
|
||||
const { data: observations = [] } = useQuery({ queryKey: ['comm-obs', tenantId], queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }), enabled: Boolean(tenantId), staleTime: 60_000 });
|
||||
const { data: contacts = [] } = useQuery({ queryKey: ['comm-contacts', tenantId], queryFn: () => apiFetch<Contact[]>('/v1/contacts?limit=500', { tenantId }), enabled: Boolean(tenantId), staleTime: 120_000 });
|
||||
|
||||
const communities = useMemo(() =>
|
||||
observations.filter((o) =>
|
||||
o.subjectType === 'community' ||
|
||||
COMMUNITY_TAGS.some((k) => o.metric.toLowerCase().includes(k))
|
||||
).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
|
||||
[observations]);
|
||||
|
||||
const communityContacts = useMemo(() =>
|
||||
contacts.filter((c) => c.tags.some((t) => COMMUNITY_TAGS.includes(t.toLowerCase()))),
|
||||
[contacts]);
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: () => apiFetch('/v1/observations', { tenantId, method: 'POST', body: {
|
||||
metric: form.type, value: form.name,
|
||||
source: form.url || undefined, unit: form.level,
|
||||
subjectType: 'community',
|
||||
observedAt: form.since ? new Date(form.since).toISOString() : new Date().toISOString(),
|
||||
confidence: form.level === 'core' ? 1 : form.level === 'active' ? 0.8 : 0.5,
|
||||
}}),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['comm-obs', tenantId] }); setShowAdd(false); setForm({ name: '', url: '', type: 'community', level: 'active', since: '' }); },
|
||||
});
|
||||
|
||||
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">Comunități & Rețele</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{communities.length} comunități · {communityContacts.length} contacte din comunități
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowAdd(true)}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
|
||||
+ Adaugă comunitate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<p className="text-sm font-semibold text-ink">Comunitate nouă</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<input placeholder="Nume comunitate (ex: AI Founders Slack)" value={form.name}
|
||||
onChange={(e) => setForm((p) => ({ ...p, name: 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" />
|
||||
<input placeholder="URL / Link" value={form.url}
|
||||
onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))}
|
||||
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring" />
|
||||
<select value={form.type} onChange={(e) => setForm((p) => ({ ...p, type: 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="community">Comunitate</option>
|
||||
<option value="slack">Slack workspace</option>
|
||||
<option value="discord">Discord server</option>
|
||||
<option value="forum">Forum</option>
|
||||
<option value="asociatie">Asociație / ONG</option>
|
||||
<option value="club">Club profesional</option>
|
||||
</select>
|
||||
<select value={form.level} onChange={(e) => setForm((p) => ({ ...p, level: 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">
|
||||
{ENGAGEMENT_LEVELS.map((l) => <option key={l.level} value={l.level}>{l.icon} {l.label} — {l.desc}</option>)}
|
||||
</select>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] text-ink-faint">Membru din</label>
|
||||
<input type="date" value={form.since} onChange={(e) => setForm((p) => ({ ...p, since: 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>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMut.mutate()} disabled={!form.name || addMut.isPending}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
|
||||
{addMut.isPending ? 'Se salvează…' : 'Salvează'}
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{communities.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 comunitate. Adaugă Slack-uri, Discord-uri, asociații profesionale.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{communities.map((c) => {
|
||||
const engLevel = ENGAGEMENT_LEVELS.find((l) => l.level === c.unit) ?? ENGAGEMENT_LEVELS[1];
|
||||
return (
|
||||
<div key={c.id} className="card p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-ink">{c.value}</p>
|
||||
<p className="text-[10px] text-ink-faint capitalize">{c.metric}</p>
|
||||
</div>
|
||||
<span className="text-sm shrink-0">{engLevel.icon}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px] text-ink-faint">
|
||||
<span className={`rounded-full px-2 py-0.5 ${c.unit === 'core' ? 'bg-primary/10 text-primary' : 'bg-muted text-ink-faint'}`}>
|
||||
{engLevel.label}
|
||||
</span>
|
||||
{c.source && (
|
||||
<a href={c.source.startsWith('http') ? c.source : undefined}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
className="text-primary hover:underline">Link →</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue