Files
mkd/components/pr/PublicationSchedule.tsx

457 lines
27 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, Image as ImageIcon, Upload, Edit } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
import type { ScheduledPost, SMMChannel } from '../../types';
export const PublicationSchedule: React.FC = () => {
const [selectedMonth, setSelectedMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
});
// Посты (отложенные)
const [posts, setPosts] = useState<ScheduledPost[]>([]);
const [postsLoading, setPostsLoading] = useState(false);
// Каналы для постов
const [channels, setChannels] = useState<SMMChannel[]>([]);
// Модалки
const [postModal, setPostModal] = useState(false);
const [viewPostModal, setViewPostModal] = useState<ScheduledPost | null>(null);
// Формы
const [postForm, setPostForm] = useState({
title: '',
content: '',
channelIds: [] as number[],
scheduledAt: '',
image: null as File | null,
imagePreview: '' as string | ''
});
const [editingPost, setEditingPost] = useState<ScheduledPost | null>(null);
const [saving, setSaving] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [editContent, setEditContent] = useState('');
useEffect(() => {
loadPosts();
loadChannels();
}, [selectedMonth]);
const loadPosts = () => {
setPostsLoading(true);
const from = `${selectedMonth}-01T00:00:00`;
const to = new Date(new Date(selectedMonth + '-01').setMonth(new Date(selectedMonth + '-01').getMonth() + 1)).toISOString();
backendApi.getScheduledPosts({ from, to, limit: 100 })
.then((data) => setPosts(Array.isArray(data) ? data : []))
.catch(() => setPosts([]))
.finally(() => setPostsLoading(false));
};
const loadChannels = () => {
backendApi.getSMMChannels()
.then((data) => setChannels(Array.isArray(data) ? data : []))
.catch(() => setChannels([]));
};
const openPostCreate = () => {
setEditingPost(null);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(10, 0, 0, 0);
setPostForm({
title: '',
content: '',
channelIds: [],
scheduledAt: tomorrow.toISOString().slice(0, 16),
image: null,
imagePreview: ''
});
setPostModal(true);
};
const openPostEdit = (post: ScheduledPost) => {
setEditingPost(post);
setPostForm({
title: post.title,
content: post.content,
channelIds: post.channelIds || [],
scheduledAt: new Date(post.scheduledAt).toISOString().slice(0, 16),
image: null,
imagePreview: post.imageUrl || ''
});
setPostModal(true);
};
const savePost = () => {
const title = postForm.title.trim();
const content = postForm.content.trim();
if (!title || !content || !postForm.scheduledAt) return;
setSaving(true);
if (editingPost) {
backendApi.updateScheduledPost(editingPost.id, {
title,
content,
channelIds: postForm.channelIds,
scheduledAt: postForm.scheduledAt,
image: postForm.image || undefined,
removeImage: !postForm.image && !postForm.imagePreview ? true : undefined
})
.then(() => { setPostModal(false); loadPosts(); })
.finally(() => setSaving(false));
} else {
backendApi.createScheduledPost({
title,
content,
channelIds: postForm.channelIds,
scheduledAt: postForm.scheduledAt,
status: 'pending_approval', // Сразу отправляем на утверждение
image: postForm.image || undefined
})
.then(() => { setPostModal(false); loadPosts(); })
.finally(() => setSaving(false));
}
};
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setPostForm(f => ({ ...f, image: file, imagePreview: URL.createObjectURL(file) }));
}
};
const handleApprovePost = (id: number) => {
setActionLoading(true);
backendApi.approveScheduledPost(id)
.then(() => { setViewPostModal(null); loadPosts(); })
.finally(() => setActionLoading(false));
};
const handleRejectPost = (id: number) => {
const reason = prompt('Укажите причину отклонения:');
if (!reason) return;
setActionLoading(true);
backendApi.rejectScheduledPost(id, { rejectionReason: reason })
.then(() => { setViewPostModal(null); loadPosts(); })
.finally(() => setActionLoading(false));
};
const handleSendToEdit = (id: number) => {
if (!editContent.trim()) return;
setActionLoading(true);
backendApi.sendScheduledPostToEdit(id, { editedContent: editContent })
.then(() => { setViewPostModal(null); setEditContent(''); loadPosts(); })
.finally(() => setActionLoading(false));
};
const handleDeletePost = (id: number) => {
if (!confirm('Удалить пост?')) return;
backendApi.deleteScheduledPost(id).then(() => loadPosts());
};
const getStatusColor = (status: string) => {
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';
case 'published': return 'text-blue-600 bg-blue-50';
case 'edited': return 'text-purple-600 bg-purple-50';
default: return 'text-slate-500 bg-slate-50';
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'draft': return 'Черновик';
case 'pending_approval': return 'На согласовании';
case 'approved': return 'Одобрено';
case 'rejected': return 'Отклонено';
case 'edited': return 'На редактировании';
case 'published': return 'Опубликовано';
default: return status;
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<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>
<button
type="button"
onClick={openPostCreate}
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>
{/* Список отложенных постов */}
{postsLoading ? (
<div className="py-8 text-center text-slate-400 text-sm">Загрузка...</div>
) : posts.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>
<button type="button" onClick={openPostCreate} className="px-4 py-2 bg-primary-600 text-white rounded-xl text-xs font-bold">Создать пост</button>
</div>
) : (
<div className="space-y-3">
{posts.map((post) => (
<div key={post.id} className="group cursor-pointer border border-slate-100 rounded-xl p-3 hover:border-primary-200 transition-colors bg-white" onClick={() => setViewPostModal(post)}>
<div className="flex justify-between items-start mb-1">
<span className="text-[9px] font-black text-slate-400 uppercase">
{new Date(post.scheduledAt).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
</span>
<span className={`text-[8px] font-bold px-1.5 py-0.5 rounded ${getStatusColor(post.status)}`}>
{getStatusLabel(post.status)}
</span>
</div>
<p className="text-xs font-bold text-slate-700 group-hover:text-primary-600 transition-colors truncate">{post.title}</p>
{post.channelIds && post.channelIds.length > 0 && (
<p className="text-[8px] font-black text-primary-500 uppercase mt-1">
{post.channelIds.length} канал{post.channelIds.length > 1 ? 'ов' : ''}
</p>
)}
{post.imageUrl && (
<div className="mt-2">
<ImageIcon className="w-4 h-4 text-slate-400 inline" />
<span className="text-[8px] text-slate-500 ml-1">Есть изображение</span>
</div>
)}
</div>
))}
</div>
)}
{/* Модалка создания/редактирования поста */}
{postModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => !saving && setPostModal(false)}>
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-center mb-4">
<h3 className="font-black text-slate-800">{editingPost ? 'Редактировать пост' : 'Создать отложенный пост'}</h3>
<button type="button" onClick={() => !saving && setPostModal(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={postForm.title}
onChange={(e) => setPostForm((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>
<input
type="datetime-local"
value={postForm.scheduledAt}
onChange={(e) => setPostForm((f) => ({ ...f, scheduledAt: e.target.value }))}
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={postForm.content}
onChange={(e) => setPostForm((f) => ({ ...f, content: e.target.value }))}
placeholder="Текст поста"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm resize-none"
rows={8}
/>
</div>
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Изображение (необязательно)</label>
<div className="space-y-2">
{postForm.imagePreview && (
<div className="relative">
<img src={postForm.imagePreview} alt="Preview" className="max-w-xs rounded-lg border border-slate-200" />
<button
type="button"
onClick={() => setPostForm(f => ({ ...f, image: null, imagePreview: '' }))}
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full"
>
<X className="w-4 h-4" />
</button>
</div>
)}
<label className="flex items-center gap-2 px-4 py-2.5 border border-slate-200 rounded-xl cursor-pointer hover:bg-slate-50">
<Upload className="w-4 h-4 text-slate-400" />
<span className="text-sm text-slate-600">Загрузить изображение</span>
<input type="file" accept="image/*" onChange={handleImageChange} className="hidden" />
</label>
</div>
</div>
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Каналы</label>
<div className="space-y-2">
{channels.map((ch) => (
<label key={ch.id} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={postForm.channelIds.includes(ch.id)}
onChange={(e) => {
if (e.target.checked) {
setPostForm((f) => ({ ...f, channelIds: [...f.channelIds, ch.id] }));
} else {
setPostForm((f) => ({ ...f, channelIds: f.channelIds.filter(id => id !== ch.id) }));
}
}}
className="w-4 h-4 text-primary-600 rounded"
/>
<span className="text-sm text-slate-700">{ch.name}</span>
</label>
))}
</div>
</div>
</div>
<div className="flex gap-2 justify-end mt-6">
<button type="button" onClick={() => !saving && setPostModal(false)} className="px-4 py-2 text-slate-600 font-bold text-sm">Отмена</button>
<button type="button" onClick={savePost} disabled={saving || !postForm.title.trim() || !postForm.content.trim() || !postForm.scheduledAt} className="px-4 py-2 bg-primary-600 text-white rounded-xl font-bold text-sm disabled:opacity-50">
{saving ? 'Сохранение...' : editingPost ? 'Сохранить изменения' : 'Создать и отправить на утверждение'}
</button>
</div>
</div>
</div>
)}
{/* Модалка просмотра/одобрения поста */}
{viewPostModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => !actionLoading && setViewPostModal(null)}>
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto 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={() => !actionLoading && setViewPostModal(null)} className="p-1 text-slate-400 hover:text-slate-600"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-4 mb-6">
<div>
<p className="text-[10px] font-bold uppercase text-slate-500 mb-1">Тема</p>
<p className="text-base font-bold text-slate-800">{viewPostModal.title}</p>
</div>
<div>
<p className="text-[10px] font-bold uppercase text-slate-500 mb-1">Дата публикации</p>
<p className="text-sm text-slate-700">{new Date(viewPostModal.scheduledAt).toLocaleString('ru-RU')}</p>
</div>
<div>
<p className="text-[10px] font-bold uppercase text-slate-500 mb-1">Содержание</p>
<div className="bg-slate-50 rounded-xl p-4 text-sm text-slate-700 whitespace-pre-wrap">{viewPostModal.content}</div>
</div>
{viewPostModal.imageUrl && (
<div>
<p className="text-[10px] font-bold uppercase text-slate-500 mb-1">Изображение</p>
<img src={viewPostModal.imageUrl} alt={viewPostModal.title} className="max-w-full rounded-lg border border-slate-200" />
</div>
)}
{viewPostModal.channelIds && viewPostModal.channelIds.length > 0 && (
<div>
<p className="text-[10px] font-bold uppercase text-slate-500 mb-1">Каналы</p>
<div className="flex gap-2 flex-wrap">
{channels.filter(ch => viewPostModal.channelIds?.includes(ch.id)).map(ch => (
<span key={ch.id} className="px-2 py-1 bg-slate-100 rounded text-xs text-slate-700">{ch.name}</span>
))}
</div>
</div>
)}
<div className="flex gap-2 flex-wrap">
<span className={`text-[10px] font-bold px-2 py-1 rounded ${getStatusColor(viewPostModal.status)}`}>
{getStatusLabel(viewPostModal.status)}
</span>
</div>
{viewPostModal.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">{viewPostModal.rejectionReason}</p>
</div>
)}
</div>
{viewPostModal.status === 'draft' && (
<div className="flex gap-2 border-t border-slate-200 pt-4">
<button
type="button"
onClick={() => {
openPostEdit(viewPostModal);
setViewPostModal(null);
}}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-xl font-bold text-sm"
>
<Pencil className="w-4 h-4 inline mr-2" /> Редактировать
</button>
<button
type="button"
onClick={() => {
backendApi.updateScheduledPost(viewPostModal.id, { status: 'pending_approval' })
.then(() => { setViewPostModal(null); loadPosts(); });
}}
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-bold text-sm"
>
Отправить на утверждение
</button>
<button
type="button"
onClick={() => handleDeletePost(viewPostModal.id)}
className="px-4 py-2 bg-red-100 text-red-600 rounded-xl font-bold text-sm"
>
<Trash2 className="w-4 h-4 inline mr-2" /> Удалить
</button>
</div>
)}
{viewPostModal.status === 'pending_approval' && (
<div className="space-y-3 border-t border-slate-200 pt-4">
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Редактировать текст (необязательно)</label>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm resize-none"
rows={4}
placeholder="Введите отредактированный текст поста"
/>
</div>
<div className="flex gap-2 flex-wrap">
<button
type="button"
onClick={() => handleApprovePost(viewPostModal.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={() => handleRejectPost(viewPostModal.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>
{editContent !== viewPostModal.content && editContent.trim() && (
<button
type="button"
onClick={() => handleSendToEdit(viewPostModal.id)}
disabled={actionLoading}
className="px-4 py-2.5 bg-blue-600 text-white rounded-xl font-bold text-sm disabled:opacity-50 flex items-center gap-2"
>
<Edit className="w-4 h-4" /> Отправить на редактирование
</button>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
};