1fbdc9f9b9
AdminPanel.js: sidebar nav с 4 разделами (Настройки API, ЮKassa, Расходы AI, Пользователи) Встроены: SettingsSection (API-ключи), SpendingSection (расходы), AdminBilling Breadcrumb навигация /system/page.js: теперь рендерит AdminPanel Header: 'Расходы' → 'Админ' (ссылка на /system), убран TrendingUp BackButton.js: переиспользуемая кнопка назад Добавлена на /drafts, /billing, /plans
226 lines
9.1 KiB
JavaScript
226 lines
9.1 KiB
JavaScript
'use client';
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { Clock, Check, X, Edit3, Trash2, RefreshCw, Loader2, Calendar, Image as ImgIcon, Zap } from 'lucide-react';
|
|
import Link from 'next/link';
|
|
import BackButton from '@/components/BackButton';
|
|
|
|
const STATUS_TABS = [
|
|
{ v: 'pending', label: 'Ожидают', color: 'text-accent' },
|
|
{ v: 'approved', label: 'Одобрено', color: 'text-green-400' },
|
|
{ v: 'rejected', label: 'Отклонено', color: 'text-gray-500' },
|
|
];
|
|
|
|
function timeAgo(s) {
|
|
const d = new Date(s), now = new Date();
|
|
const diff = now - d;
|
|
if (diff < 3600000) return Math.floor(diff / 60000) + ' мин назад';
|
|
if (diff < 86400000) return Math.floor(diff / 3600000) + 'ч назад';
|
|
return d.toLocaleDateString('ru-RU');
|
|
}
|
|
|
|
export default function DraftsPage() {
|
|
const [tab, setTab] = useState('pending');
|
|
const [drafts, setDrafts] = useState([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [loading, setLoading]= useState(true);
|
|
const [editing, setEditing]= useState(null); // draft id
|
|
const [editText,setEditText]=useState('');
|
|
const [schedMap,setSchedMap]=useState({}); // draftId → scheduledAt
|
|
const [busy, setBusy] = useState({});
|
|
|
|
const load = useCallback(async (t = tab) => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(`/api/drafts?status=${t}&limit=50`).then(r => r.json());
|
|
setDrafts(res.drafts || []);
|
|
setTotal(res.total || 0);
|
|
} catch {}
|
|
setLoading(false);
|
|
}, [tab]);
|
|
|
|
useEffect(() => { load(tab); }, [tab]);
|
|
|
|
async function doApprove(id) {
|
|
setBusy(b => ({ ...b, [id]: true }));
|
|
const res = await fetch(`/api/drafts/${id}/approve`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ scheduled_at: schedMap[id] || null }),
|
|
}).then(r => r.json());
|
|
setBusy(b => ({ ...b, [id]: false }));
|
|
if (res.ok) load(tab); else alert(res.error);
|
|
}
|
|
|
|
async function doReject(id) {
|
|
setBusy(b => ({ ...b, [id]: true }));
|
|
await fetch(`/api/drafts/${id}/reject`, { method: 'POST' });
|
|
setBusy(b => ({ ...b, [id]: false }));
|
|
load(tab);
|
|
}
|
|
|
|
async function doDelete(id) {
|
|
if (!confirm('Удалить черновик?')) return;
|
|
await fetch(`/api/drafts/${id}`, { method: 'DELETE' });
|
|
load(tab);
|
|
}
|
|
|
|
async function saveEdit(id) {
|
|
await fetch(`/api/drafts/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ text: editText }),
|
|
});
|
|
setEditing(null);
|
|
load(tab);
|
|
}
|
|
|
|
const pendingCount = tab === 'pending' ? total : 0;
|
|
|
|
return (
|
|
<main className="max-w-3xl mx-auto p-4 sm:p-6">
|
|
<BackButton />
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-xl font-bold flex items-center gap-2">
|
|
<Zap className="w-5 h-5 text-accent" /> Черновики
|
|
</h1>
|
|
<p className="text-sm text-gray-400 mt-0.5">
|
|
{tab === 'pending' && total > 0
|
|
? `${total} ${total === 1 ? 'пост ждёт' : 'поста ждут'} одобрения`
|
|
: 'Авто-генерированные и пакетные посты на проверку'}
|
|
</p>
|
|
</div>
|
|
<button onClick={() => load(tab)} className="btn-ghost p-2">
|
|
<RefreshCw className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Табы */}
|
|
<div className="flex gap-1 mb-5">
|
|
{STATUS_TABS.map(t => (
|
|
<button key={t.v} onClick={() => setTab(t.v)}
|
|
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
|
tab === t.v ? `bg-accent/10 ${t.color} font-medium` : 'text-gray-500 hover:text-gray-300'
|
|
}`}>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{loading && <div className="py-12 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
|
|
|
|
{!loading && drafts.length === 0 && (
|
|
<div className="py-16 text-center text-gray-500">
|
|
<Zap className="w-10 h-10 mx-auto mb-3 opacity-20" />
|
|
<div className="text-sm">
|
|
{tab === 'pending'
|
|
? <>Нет черновиков. Включите авто-генерацию в <Link href="/" className="text-accent hover:underline">настройках канала</Link> или сгенерируйте вручную.</>
|
|
: 'Нет записей'}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-4">
|
|
{drafts.map(draft => (
|
|
<div key={draft.id} className="card p-4 space-y-3">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<span className="font-medium text-gray-300">{draft.channel_name}</span>
|
|
{draft.platform && <span className="text-xs text-gray-500 px-1.5 py-0.5 bg-surface2 rounded">{draft.platform}</span>}
|
|
</div>
|
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
|
<Clock className="w-3 h-3" />
|
|
{timeAgo(draft.created_at)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Тема */}
|
|
{draft.topic && (
|
|
<div className="text-xs text-accent/80 flex items-center gap-1">
|
|
<span>💡</span> {draft.topic}
|
|
</div>
|
|
)}
|
|
|
|
{/* Текст */}
|
|
{editing === draft.id ? (
|
|
<div className="space-y-2">
|
|
<textarea
|
|
rows={6}
|
|
value={editText}
|
|
onChange={e => setEditText(e.target.value)}
|
|
className="input w-full text-sm resize-none font-mono"
|
|
autoFocus
|
|
/>
|
|
<div className="flex gap-2">
|
|
<button onClick={() => saveEdit(draft.id)} className="btn-primary text-sm px-3 py-1.5">Сохранить</button>
|
|
<button onClick={() => setEditing(null)} className="btn-ghost text-sm px-3 py-1.5">Отмена</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-gray-200 whitespace-pre-wrap leading-relaxed bg-surface2 rounded-lg p-3 max-h-48 overflow-y-auto">
|
|
{draft.text}
|
|
</div>
|
|
)}
|
|
|
|
{/* Изображение */}
|
|
{draft.image_url && (
|
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
|
<ImgIcon className="w-3.5 h-3.5" />
|
|
<span>Картинка прикреплена</span>
|
|
<a href={draft.image_url} target="_blank" rel="noreferrer" className="text-accent hover:underline">просмотр</a>
|
|
</div>
|
|
)}
|
|
|
|
{/* Действия */}
|
|
{draft.status === 'pending' && (
|
|
<div className="flex flex-wrap items-center gap-2 pt-1">
|
|
{/* Время публикации */}
|
|
<input
|
|
type="datetime-local"
|
|
value={schedMap[draft.id] || ''}
|
|
onChange={e => setSchedMap(m => ({ ...m, [draft.id]: e.target.value }))}
|
|
className="input text-xs py-1.5 w-48"
|
|
title="Время публикации (оставьте пустым для ближайшего слота)"
|
|
/>
|
|
|
|
<button onClick={() => doApprove(draft.id)} disabled={busy[draft.id]}
|
|
className="btn-primary text-sm px-3 py-1.5 flex items-center gap-1.5">
|
|
{busy[draft.id] ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
|
|
Одобрить
|
|
</button>
|
|
|
|
<button onClick={() => { setEditing(draft.id); setEditText(draft.text); }}
|
|
className="btn-ghost text-sm px-3 py-1.5 flex items-center gap-1.5">
|
|
<Edit3 className="w-3.5 h-3.5" /> Редактировать
|
|
</button>
|
|
|
|
<button onClick={() => doReject(draft.id)} disabled={busy[draft.id]}
|
|
className="btn-ghost text-sm px-3 py-1.5 text-gray-500 flex items-center gap-1.5">
|
|
<X className="w-3.5 h-3.5" /> Отклонить
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{draft.status === 'approved' && (
|
|
<div className="flex items-center gap-2 text-xs text-green-400">
|
|
<Check className="w-3.5 h-3.5" />
|
|
Одобрен · запланирован на {draft.scheduled_at ? new Date(draft.scheduled_at).toLocaleString('ru-RU') : '—'}
|
|
</div>
|
|
)}
|
|
|
|
{draft.status === 'rejected' && (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-gray-500">Отклонён</span>
|
|
<button onClick={() => doDelete(draft.id)} className="btn-ghost p-1.5 text-gray-600 hover:text-red-400">
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|