457 lines
27 KiB
TypeScript
Executable File
457 lines
27 KiB
TypeScript
Executable File
|
||
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>
|
||
);
|
||
};
|