Files
mkd/components/pr/PostTopicsManager.tsx
2026-02-04 00:17:04 +05:00

394 lines
24 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};