feat(CC-091): add Content Calendar page (platform filter, status flow idea→published, add form)
This commit is contained in:
parent
0bfcfc2d6b
commit
c4a7c4f07e
1 changed files with 214 additions and 0 deletions
214
src/app/dashboard/content-calendar/page.tsx
Normal file
214
src/app/dashboard/content-calendar/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue