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

457 lines
27 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, 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>
);
};