feat(CC-091): add Content Calendar page (platform filter, status flow idea→published, add form)

This commit is contained in:
admin-valentin 2026-08-02 18:06:06 +00:00
parent 0bfcfc2d6b
commit c4a7c4f07e

View file

@ -0,0 +1,214 @@
'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 CONTENT_TAGS = ['content','post','articol','newsletter','video','podcast','reel','thread','blog'];
const PLATFORM_TAGS = ['linkedin','twitter','facebook','instagram','youtube','substack','blog','medium'];
const STATUS_FLOW = ['idea','draft','review','scheduled','published'];
const STATUS_LABELS: Record<string, string> = {
idea:'💡 Idee', draft:'✏️ Draft', review:'🔍 Review', scheduled:'📅 Programat', published:'✅ Publicat', todo:'📋 Todo', 'in-progress':'⚡ În progres', completed:'✅ Done',
};
const STATUS_COLORS: Record<string, string> = {
idea:'bg-muted text-ink-faint', draft:'bg-primary/10 text-primary',
review:'bg-warn/10 text-warn', scheduled:'bg-blue-500/10 text-blue-600',
published:'bg-signal-ok/10 text-signal-ok', completed:'bg-signal-ok/10 text-signal-ok',
};
export default function ContentCalendarPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [platformFilter, setPlatformFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState({ title: '', platform: 'linkedin', type: 'post', targetDate: '', priority: 'normal' });
const { data: tasks = [], isLoading } = useQuery({
queryKey: ['content', tenantId],
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const content = useMemo(() =>
tasks.filter((t) => t.tags.some((tag) => CONTENT_TAGS.includes(tag.toLowerCase()))),
[tasks]);
function detectPlatform(t: Task): string {
return t.tags.find((tag) => PLATFORM_TAGS.includes(tag.toLowerCase())) ?? 'general';
}
function detectStatus(t: Task): string {
const knownStatuses = [...STATUS_FLOW, 'todo', 'in-progress', 'completed', 'cancelled'];
const fromTags = t.tags.find((tag) => STATUS_FLOW.includes(tag.toLowerCase()));
if (fromTags) return fromTags;
if (t.status === 'completed') return 'published';
if (t.status === 'in-progress') return 'draft';
return 'idea';
}
const platformCounts = useMemo(() => {
const map: Record<string, number> = {};
for (const t of content) {
const p = detectPlatform(t);
map[p] = (map[p] ?? 0) + 1;
}
return map;
}, [content]);
const filtered = useMemo(() => {
let list = content;
if (platformFilter !== 'all') list = list.filter((t) => detectPlatform(t) === platformFilter);
if (statusFilter !== 'all') list = list.filter((t) => detectStatus(t) === statusFilter);
return list.sort((a, b) => {
const aDate = a.targetDate ?? a.createdAt;
const bDate = b.targetDate ?? b.createdAt;
return aDate.localeCompare(bDate);
});
}, [content, platformFilter, statusFilter]);
const addMut = useMutation({
mutationFn: () => apiFetch('/v1/tasks', { tenantId, method: 'POST', body: {
title: form.title,
tags: ['content', form.type, form.platform, 'idea'],
priority: form.priority,
status: 'todo',
targetDate: form.targetDate || undefined,
}}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['content', tenantId] });
setShowAdd(false);
setForm({ title: '', platform: 'linkedin', type: 'post', targetDate: '', priority: 'normal' });
},
});
const patchStatusMut = useMutation({
mutationFn: ({ id, nextStatus }: { id: string; nextStatus: string }) => {
const tag = STATUS_FLOW.includes(nextStatus) ? nextStatus : 'published';
return apiFetch(`/v1/tasks/${id}`, { tenantId, method: 'PATCH', body: {
status: nextStatus === 'published' ? 'completed' : 'in-progress',
tags: undefined,
}});
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['content', tenantId] }),
});
const PLATFORM_ICONS: Record<string, string> = {
linkedin:'💼', twitter:'🐦', facebook:'📘', instagram:'📸', youtube:'▶️',
substack:'📮', blog:'✍️', medium:'📰', general:'📢',
};
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">Content Calendar</h1>
<p className="text-sm text-ink-faint mt-1">{content.length} piese de conținut</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">
+ Conținut
</button>
</div>
{showAdd && (
<div className="card p-5 space-y-3">
<p className="text-sm font-semibold text-ink">Conținut nou</p>
<input placeholder="Titlu / subiect (ex: 5 greșeli în pitch-ul de investiții)" 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-3">
<select value={form.platform} onChange={(e) => setForm((p) => ({ ...p, platform: 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(PLATFORM_ICONS).filter(([k]) => k !== 'general').map(([k, icon]) => (
<option key={k} value={k}>{icon} {k.charAt(0).toUpperCase() + k.slice(1)}</option>
))}
</select>
<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">
{['post','articol','video','newsletter','reel','thread','podcast'].map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<input type="date" value={form.targetDate}
onChange={(e) => setForm((p) => ({ ...p, targetDate: 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" />
</div>
<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>
)}
{/* Filters */}
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<button onClick={() => setPlatformFilter('all')}
className={`rounded-full px-3 py-1 text-xs border ${platformFilter === 'all' ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
Toate ({content.length})
</button>
{Object.entries(platformCounts).map(([p, c]) => (
<button key={p} onClick={() => setPlatformFilter(p === platformFilter ? 'all' : p)}
className={`rounded-full px-3 py-1 text-xs border ${platformFilter === p ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
{PLATFORM_ICONS[p] ?? '📢'} {p} ({c})
</button>
))}
</div>
<div className="flex flex-wrap gap-2">
{['all', ...STATUS_FLOW].map((s) => (
<button key={s} onClick={() => setStatusFilter(s === statusFilter ? 'all' : s)}
className={`rounded-full px-3 py-1 text-[10px] border capitalize ${statusFilter === s ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
{s === 'all' ? 'Toate statusurile' : STATUS_LABELS[s] ?? s}
</button>
))}
</div>
</div>
{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-3xl">📅</p>
<p className="text-sm text-ink-faint">Niciun conținut. Adaugă idei cu tag content".</p>
</div>
) : (
<div className="card divide-y divide-border/50">
{filtered.map((t) => {
const platform = detectPlatform(t);
const status = detectStatus(t);
const nextIdx = STATUS_FLOW.indexOf(status);
const next = STATUS_FLOW[nextIdx + 1];
return (
<div key={t.id} className="flex items-center gap-3 p-4">
<span className="text-xl shrink-0">{PLATFORM_ICONS[platform] ?? '📢'}</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-ink truncate">{t.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${STATUS_COLORS[status] ?? 'bg-muted text-ink-faint'}`}>
{STATUS_LABELS[status] ?? status}
</span>
{t.targetDate && <span className="text-[10px] text-ink-faint">📅 {new Date(t.targetDate).toLocaleDateString('ro-RO')}</span>}
</div>
</div>
{next && (
<button onClick={() => patchStatusMut.mutate({ id: t.id, nextStatus: next })}
className="rounded-lg border px-2.5 py-1 text-[10px] font-medium text-ink hover:border-primary/40 shrink-0">
{STATUS_LABELS[next] ?? next}
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
}