214 lines
10 KiB
TypeScript
214 lines
10 KiB
TypeScript
'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 Task { id: string; title: string; description: string | null; status: string; priority: string | null; tags: string[]; targetDate: string | null; createdAt: string; }
|
||
|
||
const EVENT_TAGS = ['eveniment','event','conferinta','conference','workshop','meetup','webinar','summit','hackathon','networking'];
|
||
const EVENT_TYPES: Record<string, string> = {
|
||
conferinta:'🎤', conference:'🎤', workshop:'🛠️', meetup:'🤝',
|
||
webinar:'💻', summit:'🏔️', hackathon:'⚡', networking:'🌐',
|
||
eveniment:'📅', event:'📅',
|
||
};
|
||
|
||
function daysUntil(iso: string | null): number | null {
|
||
if (!iso) return null;
|
||
return Math.ceil((new Date(iso).getTime() - Date.now()) / 86_400_000);
|
||
}
|
||
|
||
export default function EventsPage() {
|
||
const { activeTenant } = useSession();
|
||
const tenantId = activeTenant?.tenantId ?? '';
|
||
const qc = useQueryClient();
|
||
const [tab, setTab] = useState<'upcoming' | 'past'>('upcoming');
|
||
const [showAdd, setShowAdd] = useState(false);
|
||
const [form, setForm] = useState({ title: '', type: 'conferinta', date: '', location: '', url: '', prep: false });
|
||
|
||
const { data: tasks = [], isLoading } = useQuery({
|
||
queryKey: ['events', tenantId],
|
||
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }),
|
||
enabled: Boolean(tenantId), staleTime: 60_000,
|
||
});
|
||
|
||
const events = useMemo(() =>
|
||
tasks.filter((t) => t.tags.some((tag) => EVENT_TAGS.includes(tag.toLowerCase()))),
|
||
[tasks]);
|
||
|
||
const upcoming = useMemo(() =>
|
||
events
|
||
.filter((e) => !e.targetDate || new Date(e.targetDate) >= new Date(new Date().toDateString()))
|
||
.filter((e) => e.status !== 'cancelled')
|
||
.sort((a, b) => (a.targetDate ?? '9999').localeCompare(b.targetDate ?? '9999')),
|
||
[events]);
|
||
|
||
const past = useMemo(() =>
|
||
events
|
||
.filter((e) => e.targetDate && new Date(e.targetDate) < new Date(new Date().toDateString()))
|
||
.sort((a, b) => b.targetDate!.localeCompare(a.targetDate!)),
|
||
[events]);
|
||
|
||
const urgent = upcoming.filter((e) => {
|
||
const d = daysUntil(e.targetDate);
|
||
return d !== null && d <= 14;
|
||
});
|
||
|
||
const addMut = useMutation({
|
||
mutationFn: () => apiFetch('/v1/tasks', { tenantId, method: 'POST', body: {
|
||
title: form.title,
|
||
description: [form.location && `📍 ${form.location}`, form.url && `🔗 ${form.url}`].filter(Boolean).join('\n') || undefined,
|
||
tags: ['eveniment', form.type, ...(form.prep ? ['prep-needed'] : [])],
|
||
status: 'todo', priority: 'normal',
|
||
targetDate: form.date || undefined,
|
||
}}),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['events', tenantId] });
|
||
setShowAdd(false);
|
||
setForm({ title: '', type: 'conferinta', date: '', location: '', url: '', prep: false });
|
||
},
|
||
});
|
||
|
||
const patchMut = useMutation({
|
||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||
apiFetch(`/v1/tasks/${id}`, { tenantId, method: 'PATCH', body: { status } }),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: ['events', tenantId] }),
|
||
});
|
||
|
||
function eventType(t: Task): string {
|
||
return t.tags.find((tag) => EVENT_TYPES[tag.toLowerCase()]) ?? 'event';
|
||
}
|
||
|
||
const displayList = tab === 'upcoming' ? upcoming : past;
|
||
|
||
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">Event Tracker</h1>
|
||
<p className="text-sm text-ink-faint mt-1">{upcoming.length} viitoare · {past.length} trecute</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">
|
||
+ Eveniment
|
||
</button>
|
||
</div>
|
||
|
||
{urgent.length > 0 && (
|
||
<div className="card p-4 border-warn/30 bg-warn/5 space-y-2">
|
||
<p className="text-xs font-semibold text-warn">⚡ În următoarele 14 zile</p>
|
||
{urgent.map((e) => {
|
||
const d = daysUntil(e.targetDate);
|
||
return (
|
||
<div key={e.id} className="flex items-center justify-between">
|
||
<p className="text-sm text-ink">{EVENT_TYPES[eventType(e)] ?? '📅'} {e.title}</p>
|
||
<span className={`text-xs font-bold shrink-0 ${d !== null && d <= 3 ? 'text-signal-danger' : 'text-warn'}`}>
|
||
{d === 0 ? 'Azi!' : d === 1 ? 'Mâine' : `${d} zile`}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{showAdd && (
|
||
<div className="card p-5 space-y-3">
|
||
<p className="text-sm font-semibold text-ink">Eveniment nou</p>
|
||
<input placeholder="Titlu eveniment*" value={form.title}
|
||
onChange={(e) => setForm((p) => ({ ...p, title: 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 className="grid gap-3 sm:grid-cols-2">
|
||
<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">
|
||
{Object.entries(EVENT_TYPES).filter(([k]) => !['eveniment','event'].includes(k)).map(([k, icon]) => (
|
||
<option key={k} value={k}>{icon} {k}</option>
|
||
))}
|
||
</select>
|
||
<input type="date" value={form.date}
|
||
onChange={(e) => setForm((p) => ({ ...p, date: 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="Locație (ex: Berlin sau Online)" value={form.location}
|
||
onChange={(e) => setForm((p) => ({ ...p, location: 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 eveniment (opțional)" value={form.url}
|
||
onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))}
|
||
className="rounded-lg border bg-background px-3 py-2 text-sm font-mono text-xs text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
||
</div>
|
||
<label className="flex items-center gap-2 text-sm text-ink cursor-pointer">
|
||
<input type="checkbox" checked={form.prep} onChange={(e) => setForm((p) => ({ ...p, prep: e.target.checked }))}
|
||
className="rounded" />
|
||
Necesită pregătire / prezentare
|
||
</label>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => addMut.mutate()} disabled={!form.title || addMut.isPending}
|
||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
|
||
{addMut.isPending ? '…' : 'Adaugă'}
|
||
</button>
|
||
<button onClick={() => setShowAdd(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tabs */}
|
||
<div className="flex gap-1 border-b border-border/50">
|
||
{(['upcoming', 'past'] as const).map((t) => (
|
||
<button key={t} onClick={() => setTab(t)}
|
||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${tab === t ? 'border-primary text-primary' : 'border-transparent text-ink-faint hover:text-ink'}`}>
|
||
{t === 'upcoming' ? `Viitoare (${upcoming.length})` : `Trecute (${past.length})`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="text-center text-sm text-ink-faint py-8">Se încarcă…</div>
|
||
) : displayList.length === 0 ? (
|
||
<div className="card p-8 text-center space-y-2">
|
||
<p className="text-3xl">🗓️</p>
|
||
<p className="text-sm text-ink-faint">{tab === 'upcoming' ? 'Niciun eveniment viitor.' : 'Niciun eveniment trecut.'}</p>
|
||
</div>
|
||
) : (
|
||
<div className="card divide-y divide-border/50">
|
||
{displayList.map((e) => {
|
||
const d = daysUntil(e.targetDate);
|
||
const icon = EVENT_TYPES[eventType(e)] ?? '📅';
|
||
const needsPrep = e.tags.includes('prep-needed');
|
||
const [loc, url] = (e.description ?? '').split('\n');
|
||
return (
|
||
<div key={e.id} className="flex items-start gap-3 p-4">
|
||
<span className="text-xl shrink-0 mt-0.5">{icon}</span>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-ink">{e.title}</p>
|
||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
|
||
{e.targetDate && <p className="text-[10px] text-ink-faint">📅 {new Date(e.targetDate).toLocaleDateString('ro-RO', { weekday:'short', day:'numeric', month:'short' })}</p>}
|
||
{loc?.startsWith('📍') && <p className="text-[10px] text-ink-faint">{loc}</p>}
|
||
{needsPrep && <span className="text-[10px] text-warn font-medium">🛠️ Prep needed</span>}
|
||
</div>
|
||
{url?.startsWith('🔗') && (
|
||
<p className="text-[10px] text-primary truncate mt-0.5">{url.replace('🔗 ', '')}</p>
|
||
)}
|
||
</div>
|
||
<div className="text-right shrink-0 space-y-1">
|
||
{d !== null && tab === 'upcoming' && (
|
||
<p className={`text-xs font-bold ${d <= 3 ? 'text-signal-danger' : d <= 14 ? 'text-warn' : 'text-ink-faint'}`}>
|
||
{d === 0 ? 'Azi' : d === 1 ? 'Mâine' : `${d}z`}
|
||
</p>
|
||
)}
|
||
{tab === 'past' && e.status !== 'completed' && (
|
||
<button onClick={() => patchMut.mutate({ id: e.id, status: 'completed' })}
|
||
className="rounded border px-2 py-0.5 text-[10px] text-signal-ok border-signal-ok/30 hover:bg-signal-ok/10">
|
||
✓ Participat
|
||
</button>
|
||
)}
|
||
{tab === 'past' && e.status === 'completed' && (
|
||
<span className="text-[10px] text-signal-ok">✓ Participat</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|