394 lines
24 KiB
TypeScript
394 lines
24 KiB
TypeScript
|
|
|
|||
|
|
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>
|
|||
|
|
);
|
|||
|
|
};
|