Files
mkd/components/pr/PostTopicsManager.tsx

394 lines
24 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useEffect } from 'react';
import { Plus, X, CheckCircle, XCircle, Calendar, Pencil, Trash2, Eye, FileText } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
import type { PostTopic } from '../../types';
interface PostTopicsManagerProps {
onCreatePostFromTopic?: (topic: PostTopic) => void;
}
export const PostTopicsManager: React.FC<PostTopicsManagerProps> = ({ onCreatePostFromTopic }) => {
const [list, setList] = useState<PostTopic[]>([]);
const [loading, setLoading] = useState(true);
const [selectedMonth, setSelectedMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
});
const [modalOpen, setModalOpen] = useState(false);
const [viewModal, setViewModal] = useState<PostTopic | null>(null);
const [editing, setEditing] = useState<PostTopic | null>(null);
const [form, setForm] = useState({ title: '', description: '', scheduledDate: '', month: selectedMonth });
const [saving, setSaving] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const loadList = () => {
setLoading(true);
backendApi.getPostTopics({ month: selectedMonth, limit: 100 })
.then((data) => setList(Array.isArray(data) ? data : []))
.catch(() => setList([]))
.finally(() => setLoading(false));
};
useEffect(() => {
loadList();
}, [selectedMonth]);
const openCreate = () => {
setEditing(null);
const firstDayOfMonth = `${selectedMonth}-01`;
setForm({ title: '', description: '', scheduledDate: firstDayOfMonth, month: selectedMonth });
setModalOpen(true);
};
const openEdit = (topic: PostTopic) => {
setEditing(topic);
setForm({
title: topic.title,
description: topic.description ?? '',
scheduledDate: topic.scheduledDate ? topic.scheduledDate.slice(0, 10) : `${topic.month}-01`,
month: topic.month
});
setModalOpen(true);
};
const handleSave = () => {
const title = form.title.trim();
if (!title || !form.scheduledDate) return;
setSaving(true);
const monthVal = form.month || form.scheduledDate.slice(0, 7);
if (editing) {
backendApi.updatePostTopic(editing.id, {
title,
description: form.description.trim() || undefined,
scheduledDate: form.scheduledDate
})
.then(() => { setModalOpen(false); loadList(); })
.finally(() => setSaving(false));
} else {
backendApi.createPostTopic({
title,
description: form.description.trim() || undefined,
scheduledDate: form.scheduledDate,
month: monthVal
})
.then(() => { setModalOpen(false); loadList(); })
.finally(() => setSaving(false));
}
};
const handleApprove = (id: number) => {
if (!confirm('Одобрить тему графика публикации?')) return;
setActionLoading(true);
backendApi.approvePostTopic(id)
.then(() => { setViewModal(null); loadList(); })
.finally(() => setActionLoading(false));
};
const handleReject = (id: number) => {
const reason = prompt('Укажите причину отклонения:');
if (!reason) return;
setActionLoading(true);
backendApi.rejectPostTopic(id, { rejectionReason: reason })
.then(() => { setViewModal(null); loadList(); })
.finally(() => setActionLoading(false));
};
const handleSendToApproval = (id: number) => {
setActionLoading(true);
backendApi.updatePostTopic(id, { status: 'pending_approval' })
.then(() => { setModalOpen(false); loadList(); })
.finally(() => setActionLoading(false));
};
const handleDelete = (id: number) => {
if (!confirm('Удалить тему из графика?')) return;
backendApi.deletePostTopic(id).then(() => loadList());
};
const getStatusColor = (status: PostTopic['status']) => {
switch (status) {
case 'approved': return 'text-emerald-600 bg-emerald-50';
case 'pending_approval': return 'text-amber-600 bg-amber-50';
case 'rejected': return 'text-red-600 bg-red-50';
default: return 'text-slate-500 bg-slate-50';
}
};
const getStatusLabel = (status: PostTopic['status']) => {
switch (status) {
case 'draft': return 'Черновик';
case 'pending_approval': return 'На согласовании';
case 'approved': return 'Одобрено';
case 'rejected': return 'Отклонено';
default: return status;
}
};
const pendingCount = list.filter(t => t.status === 'pending_approval').length;
const approvedCount = list.filter(t => t.status === 'approved').length;
const approvedTopics = list.filter(t => t.status === 'approved');
// Группировка по датам для календарного вида
const topicsByDate: Record<string, PostTopic[]> = {};
list.forEach(topic => {
const date = topic.scheduledDate ? topic.scheduledDate.slice(0, 10) : `${topic.month}-01`;
if (!topicsByDate[date]) topicsByDate[date] = [];
topicsByDate[date].push(topic);
});
return (
<div className="space-y-6 animate-fade-in">
<div className="flex justify-between items-center">
<div>
<h3 className="font-black text-slate-800 text-sm">График публикации</h3>
<p className="text-[10px] text-slate-500 uppercase tracking-widest mt-0.5">
План публикаций на месяц (без контента). По этим темам создаются посты с контентом для одобрения.
</p>
</div>
<button
type="button"
onClick={openCreate}
className="flex items-center gap-2 px-4 py-2.5 bg-primary-600 text-white rounded-xl text-xs font-bold uppercase tracking-wider hover:bg-primary-700"
>
<Plus className="w-4 h-4" /> Добавить в график
</button>
</div>
<div className="flex items-center gap-4">
<label className="text-[10px] font-bold uppercase text-slate-500">Месяц</label>
<input
type="month"
value={selectedMonth}
onChange={(e) => setSelectedMonth(e.target.value)}
className="px-4 py-2 border border-slate-200 rounded-xl text-sm"
/>
</div>
{(pendingCount > 0 || approvedCount > 0) && (
<div className="grid grid-cols-2 gap-4">
{pendingCount > 0 && (
<div className="p-4 bg-amber-50 rounded-2xl border border-amber-100">
<p className="text-[10px] font-bold uppercase text-amber-600 tracking-widest">На согласовании</p>
<p className="text-2xl font-black text-amber-800">{pendingCount}</p>
</div>
)}
{approvedCount > 0 && (
<div className="p-4 bg-emerald-50 rounded-2xl border border-emerald-100">
<p className="text-[10px] font-bold uppercase text-emerald-600 tracking-widest">Одобрено</p>
<p className="text-2xl font-black text-emerald-800">{approvedCount}</p>
</div>
)}
</div>
)}
{loading ? (
<div className="py-8 text-center text-slate-400 text-sm">Загрузка...</div>
) : list.length === 0 ? (
<div className="py-12 text-center bg-white rounded-2xl border border-slate-200">
<p className="text-slate-500 text-sm mb-2">Нет записей в графике для выбранного месяца</p>
<p className="text-xs text-slate-400 mb-4">Создайте график публикаций на месяц, затем по этим темам создавайте посты с контентом</p>
<button type="button" onClick={openCreate} className="px-4 py-2 bg-primary-600 text-white rounded-xl text-xs font-bold">Добавить в график</button>
</div>
) : (
<div className="space-y-4">
{/* Календарное отображение */}
{Object.keys(topicsByDate).sort().map(date => (
<div key={date} className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="bg-slate-50 px-4 py-2 border-b border-slate-200">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary-500" />
<span className="font-bold text-slate-800">{new Date(date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', weekday: 'short' })}</span>
</div>
</div>
<div className="p-4 space-y-2">
{topicsByDate[date].map((topic) => (
<div
key={topic.id}
className="flex items-start justify-between gap-3 p-3 bg-slate-50 rounded-xl border border-slate-100"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className="font-bold text-slate-800 text-sm">{topic.title}</span>
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded ${getStatusColor(topic.status)}`}>
{getStatusLabel(topic.status)}
</span>
</div>
{topic.description && <p className="text-xs text-slate-600 line-clamp-1">{topic.description}</p>}
</div>
<div className="flex items-center gap-1 shrink-0 flex-wrap">
{topic.status === 'approved' && onCreatePostFromTopic && (
<button
type="button"
onClick={() => onCreatePostFromTopic(topic)}
className="px-3 py-1.5 bg-primary-50 text-primary-600 rounded-lg text-[10px] font-bold uppercase flex items-center gap-1 whitespace-nowrap"
title="Создать пост по этой теме"
>
<FileText className="w-3.5 h-3.5" /> Создать пост
</button>
)}
<button type="button" onClick={() => setViewModal(topic)} className="p-1.5 text-slate-400 hover:text-primary-600 rounded" title="Просмотр">
<Eye className="w-4 h-4" />
</button>
{topic.status === 'draft' && (
<>
<button type="button" onClick={() => openEdit(topic)} className="p-1.5 text-slate-400 hover:text-primary-600 rounded" title="Изменить">
<Pencil className="w-4 h-4" />
</button>
<button type="button" onClick={() => handleSendToApproval(topic.id)} disabled={actionLoading} className="px-2 py-1 bg-amber-50 text-amber-600 rounded-lg text-[9px] font-bold uppercase" title="Отправить на согласование">
На согласование
</button>
</>
)}
{topic.status === 'pending_approval' && (
<>
<button type="button" onClick={() => handleApprove(topic.id)} disabled={actionLoading} className="px-2 py-1 bg-emerald-50 text-emerald-600 rounded-lg text-[9px] font-bold uppercase flex items-center gap-1" title="Одобрить">
<CheckCircle className="w-3 h-3" /> Одобрить
</button>
<button type="button" onClick={() => handleReject(topic.id)} disabled={actionLoading} className="px-2 py-1 bg-red-50 text-red-600 rounded-lg text-[9px] font-bold uppercase flex items-center gap-1" title="Отклонить">
<XCircle className="w-3 h-3" /> Отклонить
</button>
</>
)}
<button type="button" onClick={() => handleDelete(topic.id)} className="p-1.5 text-slate-400 hover:text-red-600 rounded" title="Удалить">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* Create/Edit Modal */}
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => !saving && setModalOpen(false)}>
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-center mb-4">
<h3 className="font-black text-slate-800">{editing ? 'Редактировать тему графика' : 'Добавить в график публикации'}</h3>
<button type="button" onClick={() => !saving && setModalOpen(false)} className="p-1 text-slate-400 hover:text-slate-600"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-4">
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Тема публикации *</label>
<input
type="text"
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
placeholder="Напр.: Отчёт о работе за месяц"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm"
/>
</div>
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Описание (необязательно)</label>
<textarea
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="Краткое описание темы"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm resize-none"
rows={3}
/>
</div>
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Дата планируемой публикации *</label>
<input
type="date"
value={form.scheduledDate}
onChange={(e) => {
const date = e.target.value;
setForm((f) => ({ ...f, scheduledDate: date, month: date.slice(0, 7) }));
}}
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm"
/>
</div>
</div>
<div className="flex gap-2 justify-end mt-6">
<button type="button" onClick={() => !saving && setModalOpen(false)} className="px-4 py-2 text-slate-600 font-bold text-sm">Отмена</button>
<button type="button" onClick={handleSave} disabled={saving || !form.title.trim() || !form.scheduledDate} className="px-4 py-2 bg-primary-600 text-white rounded-xl font-bold text-sm disabled:opacity-50">
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
</div>
)}
{/* View Modal */}
{viewModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => setViewModal(null)}>
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-center mb-4">
<h3 className="font-black text-slate-800">Просмотр темы графика</h3>
<button type="button" onClick={() => setViewModal(null)} className="p-1 text-slate-400 hover:text-slate-600"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-3">
<div>
<p className="text-[10px] font-bold uppercase text-slate-500 mb-1">Тема</p>
<p className="text-base font-bold text-slate-800">{viewModal.title}</p>
</div>
{viewModal.description && (
<div>
<p className="text-[10px] font-bold uppercase text-slate-500 mb-1">Описание</p>
<p className="text-sm text-slate-700">{viewModal.description}</p>
</div>
)}
<div className="flex gap-2 flex-wrap">
<span className={`text-[10px] font-bold px-2 py-1 rounded ${getStatusColor(viewModal.status)}`}>
{getStatusLabel(viewModal.status)}
</span>
<span className="text-[10px] text-slate-500 flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" /> {viewModal.scheduledDate ? new Date(viewModal.scheduledDate).toLocaleDateString('ru-RU') : viewModal.month}
</span>
</div>
{viewModal.rejectionReason && (
<div className="bg-red-50 border border-red-200 rounded-xl p-3">
<p className="text-[10px] font-bold uppercase text-red-600 mb-1">Причина отклонения</p>
<p className="text-sm text-red-700">{viewModal.rejectionReason}</p>
</div>
)}
</div>
{viewModal.status === 'pending_approval' && (
<div className="flex gap-2 mt-6 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => handleApprove(viewModal.id)}
disabled={actionLoading}
className="flex-1 px-4 py-2.5 bg-emerald-600 text-white rounded-xl font-bold text-sm disabled:opacity-50 flex items-center justify-center gap-2"
>
<CheckCircle className="w-4 h-4" /> Одобрить
</button>
<button
type="button"
onClick={() => {
const reason = prompt('Укажите причину отклонения:');
if (reason) handleReject(viewModal.id);
}}
disabled={actionLoading}
className="px-4 py-2.5 bg-red-600 text-white rounded-xl font-bold text-sm disabled:opacity-50 flex items-center gap-2"
>
<XCircle className="w-4 h-4" /> Отклонить
</button>
</div>
)}
{viewModal.status === 'approved' && onCreatePostFromTopic && (
<div className="mt-6 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => {
onCreatePostFromTopic(viewModal);
setViewModal(null);
}}
className="w-full px-4 py-2.5 bg-primary-600 text-white rounded-xl font-bold text-sm flex items-center justify-center gap-2"
>
<FileText className="w-4 h-4" /> Создать пост по этой теме
</button>
</div>
)}
</div>
</div>
)}
</div>
);
};