Files
mkd/components/pr/SMMManager.tsx

1016 lines
63 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useEffect } from 'react';
import {
Send,
MessageSquare,
Share2,
Zap,
Sparkles,
BarChart3,
Layout,
Plus,
Hash,
ImageIcon,
Clock,
CheckCircle2,
Trash2,
X,
ChevronDown,
ChevronUp,
Lightbulb,
UserPlus,
ArrowRight,
CheckCircle,
XCircle,
Edit,
Eye,
Mail,
Calendar,
FileText,
Pencil
} from 'lucide-react';
import { backendApi } from '../../services/apiClient';
import type { SMMChannel as SMMChannelType, AttractionAction, ScheduledPost, PREvent } from '../../types';
import { PublicationSchedule } from './PublicationSchedule';
import { EVENT_SMM_POST_FROM_EVENT } from '../../constants/refreshEvents';
const ACTION_TYPE_LABELS: Record<AttractionAction['actionType'], string> = {
mailing: 'Рассылка',
event: 'Мероприятие',
post: 'Пост',
other: 'Другое',
};
const ACTION_TYPE_ICONS: Record<AttractionAction['actionType'], typeof Mail> = {
mailing: Mail,
event: Calendar,
post: FileText,
other: Hash,
};
const CHANNEL_STYLE: Record<string, { color: string; bg: string; Icon: typeof Send }> = {
tg: { color: 'text-sky-500', bg: 'bg-sky-50', Icon: Send },
vk: { color: 'text-blue-600', bg: 'bg-blue-50', Icon: Share2 },
wa: { color: 'text-emerald-500', bg: 'bg-emerald-50', Icon: MessageSquare },
other: { color: 'text-slate-600', bg: 'bg-slate-50', Icon: Hash },
};
const UK_RECOMMENDATIONS = [
'Информировать жителей о проделанной работе — отчёты, фото, сроки.',
'Вести диалог и обратную связь в комментариях, не оставлять вопросы без ответа.',
'Полезный контент: видео, истории жильцов, микрогиды по районам и дворам.',
'Единый стиль и оформление сообщества — узнаваемый бренд УК.',
'Таргетированная реклама при необходимости для привлечения в группу.',
];
const CONTENT_IDEAS = [
{ tag: 'ЖКХ Хак', title: 'Как снизить платежи за отопление: 3 совета', desc: 'Статистика показывает, что этот пост соберет +45% охвата в холодное время.' },
{ tag: 'Доверие', title: 'Знакомство с мастером: Сантехник Иван', desc: 'Персонализация снижает агрессию в чатах на 20%.' },
{ tag: 'Отчёт', title: 'Отчёт о работе за месяц', desc: 'Кратко: что сделали по дому, куда ушли средства. Повышает доверие.' },
{ tag: 'Микрогид', title: 'Микрогид по двору', desc: 'Где контейнеры, где парковка, контакты УК — удобно для новосёлов.' },
{ tag: 'Реакция', title: 'Как мы отреагировали на заявку', desc: 'История: заявка → выезд → результат. Показывает работу УК.' },
];
interface SMMManagerProps {
onNavigateToAttraction?: () => void;
}
export const SMMManager: React.FC<SMMManagerProps> = ({ onNavigateToAttraction }) => {
const [isGenerating, setIsGenerating] = useState(false);
const [channels, setChannels] = useState<SMMChannelType[]>([]);
const [channelsLoading, setChannelsLoading] = useState(true);
const [attractionList, setAttractionList] = useState<AttractionAction[]>([]);
const [attractionLoading, setAttractionLoading] = useState(false);
const [attractionModal, setAttractionModal] = useState(false);
const [attractionEditing, setAttractionEditing] = useState<AttractionAction | null>(null);
const [attractionForm, setAttractionForm] = useState({
title: '',
description: '',
channelId: '' as number | '',
actionType: 'mailing' as AttractionAction['actionType'],
actionDate: new Date().toISOString().slice(0, 10),
newSubscribersAttributed: '' as number | '',
eventId: '' as number | '',
});
const [prEvents, setPrEvents] = useState<Array<{ id: number; title: string; date: string }>>([]);
const [attractionSaving, setAttractionSaving] = useState(false);
const [recommendationsOpen, setRecommendationsOpen] = useState(false);
const [scheduledPosts, setScheduledPosts] = useState<ScheduledPost[]>([]);
const [scheduledPostsLoading, setScheduledPostsLoading] = useState(false);
const [viewPostModal, setViewPostModal] = useState<ScheduledPost | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [editContent, setEditContent] = useState('');
const [postActionLoading, setPostActionLoading] = useState(false);
const [createPostModal, setCreatePostModal] = useState(false);
const [postForm, setPostForm] = useState({ title: '', content: '', channelIds: [] as number[], scheduledAt: '', topicId: '' as number | '' });
const [postFormSaving, setPostFormSaving] = useState(false);
const [approvedTopics, setApprovedTopics] = useState<Array<{ id: number; title: string; scheduledDate: string }>>([]);
const [snapshotModal, setSnapshotModal] = useState<{ channel: SMMChannelType } | null>(null);
const [snapshotCount, setSnapshotCount] = useState('');
const [snapshotNote, setSnapshotNote] = useState('');
const [snapshotSaving, setSnapshotSaving] = useState(false);
const [addChannelModal, setAddChannelModal] = useState(false);
const [newChannelName, setNewChannelName] = useState('');
const [newChannelType, setNewChannelType] = useState<'tg' | 'vk' | 'wa' | 'other'>('tg');
const [newChannelSaving, setNewChannelSaving] = useState(false);
const loadChannels = () => {
setChannelsLoading(true);
backendApi.getSMMChannels()
.then((list) => { setChannels(Array.isArray(list) ? list : []); })
.catch(() => setChannels([]))
.finally(() => setChannelsLoading(false));
};
useEffect(() => { loadChannels(); }, []);
const loadAttractionList = () => {
setAttractionLoading(true);
backendApi.getAttractionActions({ limit: 100 })
.then((data) => setAttractionList(Array.isArray(data) ? data : []))
.catch(() => setAttractionList([]))
.finally(() => setAttractionLoading(false));
};
useEffect(() => {
loadAttractionList();
}, []);
const handleOpenAttractionCreate = () => {
setAttractionEditing(null);
setAttractionForm({
title: '',
description: '',
channelId: '',
actionType: 'mailing',
actionDate: new Date().toISOString().slice(0, 10),
newSubscribersAttributed: '',
eventId: '',
});
setAttractionModal(true);
};
const handleOpenAttractionEdit = (item: AttractionAction) => {
setAttractionEditing(item);
setAttractionForm({
title: item.title,
description: item.description ?? '',
channelId: item.channelId ?? '',
actionType: item.actionType,
actionDate: item.actionDate ? item.actionDate.slice(0, 10) : new Date().toISOString().slice(0, 10),
newSubscribersAttributed: item.newSubscribersAttributed ?? '',
eventId: item.eventId ?? '',
});
setAttractionModal(true);
};
useEffect(() => {
if (attractionModal) {
backendApi.getPREvents({ limit: 100 })
.then((list) => setPrEvents(Array.isArray(list) ? list.map(e => ({ id: Number(e.id), title: e.title, date: String(e.date) })) : []))
.catch(() => setPrEvents([]));
}
}, [attractionModal]);
const handleSaveAttraction = () => {
const title = attractionForm.title.trim();
if (!title) return;
setAttractionSaving(true);
const payload = {
title,
description: attractionForm.description.trim() || undefined,
channelId: attractionForm.channelId === '' ? undefined : Number(attractionForm.channelId),
actionType: attractionForm.actionType,
actionDate: attractionForm.actionDate,
newSubscribersAttributed: attractionForm.newSubscribersAttributed === '' ? undefined : Number(attractionForm.newSubscribersAttributed),
eventId: attractionForm.actionType === 'event' && attractionForm.eventId !== '' ? Number(attractionForm.eventId) : undefined,
};
if (attractionEditing) {
backendApi.updateAttractionAction(attractionEditing.id, payload)
.then(() => { setAttractionModal(false); loadAttractionList(); })
.finally(() => setAttractionSaving(false));
} else {
backendApi.createAttractionAction(payload)
.then(() => { setAttractionModal(false); loadAttractionList(); })
.finally(() => setAttractionSaving(false));
}
};
const handleDeleteAttraction = (id: number) => {
if (!confirm('Удалить запись о привлечении?')) return;
backendApi.deleteAttractionAction(id).then(() => loadAttractionList());
};
const loadScheduledPosts = () => {
setScheduledPostsLoading(true);
backendApi.getScheduledPosts({ limit: 10 })
.then((data) => setScheduledPosts(Array.isArray(data) ? data : []))
.catch(() => setScheduledPosts([]))
.finally(() => setScheduledPostsLoading(false));
};
useEffect(() => {
loadScheduledPosts();
}, []);
useEffect(() => {
backendApi.getPostTopics({ status: 'approved', limit: 50 })
.then((data) => {
const topics = Array.isArray(data) ? data : [];
setApprovedTopics(topics.map(t => ({ id: t.id, title: t.title, scheduledDate: t.scheduledDate || `${t.month}-01` })));
})
.catch(() => setApprovedTopics([]));
}, []);
const handleOpenCreatePost = (topic?: { id: number; title: string; scheduledDate: string }) => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(10, 0, 0, 0);
const scheduledDate = topic ? new Date(topic.scheduledDate) : tomorrow;
scheduledDate.setHours(10, 0, 0, 0);
setPostForm({
title: topic ? topic.title : '',
content: '',
channelIds: [],
scheduledAt: scheduledDate.toISOString().slice(0, 16),
topicId: topic ? topic.id : ''
});
setCreatePostModal(true);
};
useEffect(() => {
const handler = (e: CustomEvent<{ event: PREvent }>) => {
const ev = e.detail?.event;
if (!ev) return;
const eventDate = new Date(String(ev.date));
const publishDate = new Date(eventDate);
publishDate.setDate(publishDate.getDate() - 1);
publishDate.setHours(10, 0, 0, 0);
const content = ev.announcement?.trim()
|| `Приглашаем на мероприятие «${ev.title}» — ${eventDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })}. ${ev.location ? `Место: ${ev.location}.` : ''}`;
setPostForm({
title: ev.title,
content,
channelIds: [],
scheduledAt: publishDate.toISOString().slice(0, 16),
topicId: ''
});
setCreatePostModal(true);
};
window.addEventListener(EVENT_SMM_POST_FROM_EVENT, handler as EventListener);
return () => window.removeEventListener(EVENT_SMM_POST_FROM_EVENT, handler as EventListener);
}, []);
const handleSavePost = () => {
if (!postForm.title.trim() || !postForm.content.trim() || !postForm.scheduledAt) return;
setPostFormSaving(true);
backendApi.createScheduledPost({
title: postForm.title.trim(),
content: postForm.content.trim(),
channelIds: postForm.channelIds,
scheduledAt: new Date(postForm.scheduledAt).toISOString(),
status: 'pending_approval',
topicId: postForm.topicId === '' ? undefined : Number(postForm.topicId)
})
.then(() => {
setCreatePostModal(false);
loadScheduledPosts();
})
.finally(() => setPostFormSaving(false));
};
const handleViewPost = (post: ScheduledPost) => {
setViewPostModal(post);
setRejectReason('');
setEditContent(post.content);
};
const handleApprovePost = (id: number) => {
if (!confirm('Одобрить пост для публикации?')) return;
setPostActionLoading(true);
backendApi.approveScheduledPost(id)
.then((updated) => {
if (updated && viewPostModal) {
setViewPostModal({ ...viewPostModal, ...updated });
}
loadScheduledPosts();
})
.finally(() => setPostActionLoading(false));
};
const handleRejectPost = (id: number) => {
if (!rejectReason.trim()) {
alert('Укажите причину отклонения');
return;
}
setPostActionLoading(true);
backendApi.rejectScheduledPost(id, { rejectionReason: rejectReason })
.then(() => { setViewPostModal(null); setRejectReason(''); loadScheduledPosts(); })
.finally(() => setPostActionLoading(false));
};
const handleSendToEdit = (id: number) => {
if (!editContent.trim()) {
alert('Введите отредактированный текст');
return;
}
setPostActionLoading(true);
backendApi.sendScheduledPostToEdit(id, { editedContent: editContent })
.then((updated) => {
if (updated && viewPostModal) {
setViewPostModal({ ...viewPostModal, ...updated });
}
setEditContent('');
loadScheduledPosts();
})
.finally(() => setPostActionLoading(false));
};
const handleOpenSnapshot = (channel: SMMChannelType) => {
setSnapshotModal({ channel });
setSnapshotCount(String(channel.subscribersCount ?? channel.lastSnapshot?.subscribersCount ?? ''));
setSnapshotNote('');
};
const handleSaveSnapshot = () => {
if (!snapshotModal) return;
const count = parseInt(snapshotCount, 10);
if (Number.isNaN(count) || count < 0) return;
setSnapshotSaving(true);
backendApi.createSMMChannelSnapshot(snapshotModal.channel.id, {
subscribersCount: count,
note: snapshotNote.trim() || undefined,
})
.then(() => {
setSnapshotModal(null);
loadChannels();
})
.finally(() => setSnapshotSaving(false));
};
const handleAddChannel = () => {
const name = newChannelName.trim();
if (!name) return;
setNewChannelSaving(true);
backendApi.createSMMChannel({ name, type: newChannelType })
.then(() => {
setAddChannelModal(false);
setNewChannelName('');
setNewChannelType('tg');
loadChannels();
})
.finally(() => setNewChannelSaving(false));
};
const handleDeleteChannel = (id: number) => {
if (!confirm('Удалить канал? Снимки подписчиков тоже будут удалены.')) return;
backendApi.deleteSMMChannel(id).then(() => loadChannels());
};
const getStatusColor = (status: ScheduledPost['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';
case 'edited': return 'text-blue-600 bg-blue-50';
case 'published': return 'text-slate-600 bg-slate-50';
default: return 'text-slate-500 bg-slate-50';
}
};
const getStatusLabel = (status: ScheduledPost['status']) => {
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-6 animate-fade-in" data-smm-manager>
{/* AI Post Generator */}
<div className="bg-slate-900 rounded-[2.5rem] p-8 text-white shadow-xl relative overflow-hidden">
<Sparkles className="absolute -top-4 -right-4 w-48 h-48 opacity-10 rotate-12 text-primary-400" />
<div className="relative z-10">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-primary-500 rounded-2xl">
<Zap className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="text-2xl font-black">AI Контент-мейкер</h3>
<p className="text-slate-400 text-xs font-bold uppercase tracking-widest mt-1">Генерация постов из рабочих данных</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<label className="block text-[10px] font-black uppercase text-slate-500 tracking-widest">О чем пишем?</label>
<textarea
placeholder="Напр: Закончили ремонт подъезда на Мира 8, поставили новые датчики движения, стало светлее..."
className="w-full h-32 p-4 bg-white/5 border border-white/10 rounded-2xl text-sm outline-none focus:border-primary-500 transition-all resize-none"
/>
<div className="flex gap-2">
<button className="px-4 py-2 bg-white/10 rounded-xl text-[10px] font-black uppercase hover:bg-white/20 transition-all">Для жителей</button>
<button className="px-4 py-2 bg-white/10 rounded-xl text-[10px] font-black uppercase hover:bg-white/20 transition-all">Для партнеров</button>
<button className="px-4 py-2 bg-white/10 rounded-xl text-[10px] font-black uppercase hover:bg-white/20 transition-all">Профи</button>
</div>
</div>
<div className="flex flex-col justify-between">
<div className="bg-white/5 border border-dashed border-white/10 rounded-3xl p-6 flex flex-col items-center justify-center text-center">
<ImageIcon className="w-8 h-8 text-slate-500 mb-2" />
<p className="text-[10px] font-bold text-slate-400 uppercase">Добавить фото/видео</p>
<p className="text-[8px] text-slate-600 mt-1">Рекомендуем формат 4:5 для постов</p>
</div>
<div className="flex gap-2 mt-4">
<button
onClick={() => { setIsGenerating(true); setTimeout(() => setIsGenerating(false), 2000); }}
className="flex-1 py-4 bg-primary-600 text-white rounded-2xl font-black text-xs uppercase tracking-widest shadow-lg shadow-primary-500/20 active:scale-95 transition-all flex items-center justify-center gap-2"
>
{isGenerating ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Sparkles className="w-4 h-4" />}
{isGenerating ? 'Магия ИИ...' : 'Сгенерировать'}
</button>
<button
onClick={handleOpenCreatePost}
className="px-4 py-4 bg-white/10 text-white rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-white/20 transition-all flex items-center justify-center gap-2"
>
<Plus className="w-4 h-4" /> Создать пост
</button>
</div>
</div>
</div>
</div>
</div>
{/* Channels & Stats */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em] px-1">Наши медиа-ресурсы</h4>
<button
type="button"
onClick={() => setAddChannelModal(true)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 text-white rounded-xl text-[10px] font-bold uppercase tracking-wider hover:bg-primary-700"
>
<Plus className="w-3.5 h-3.5" /> Добавить канал
</button>
</div>
{channelsLoading ? (
<div className="bg-white p-8 rounded-[2rem] border border-slate-200 text-center text-slate-400 text-sm">Загрузка каналов...</div>
) : channels.length === 0 ? (
<div className="bg-white p-8 rounded-[2rem] border border-slate-200 text-center text-slate-500 text-sm">
<p className="font-bold mb-2">Нет добавленных каналов</p>
<p className="text-xs mb-4">Добавьте Telegram, ВКонтакте или WhatsApp и фиксируйте количество подписчиков.</p>
<button type="button" onClick={() => setAddChannelModal(true)} className="px-4 py-2 bg-primary-600 text-white rounded-xl text-xs font-bold">Добавить канал</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{channels.map((ch) => {
const style = CHANNEL_STYLE[ch.type] || CHANNEL_STYLE.other;
const Icon = style.Icon;
const count = ch.subscribersCount ?? ch.lastSnapshot?.subscribersCount;
return (
<div key={ch.id} className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm relative group">
<div className={`w-10 h-10 rounded-xl ${style.bg} ${style.color} flex items-center justify-center mb-3`}>
<Icon className="w-5 h-5" />
</div>
<h5 className="font-black text-slate-800 text-xs leading-tight mb-1 pr-8">{ch.name}</h5>
<p className="text-[10px] text-slate-400 font-bold uppercase">
{count != null ? `${count.toLocaleString('ru-RU')} подписчиков` : '— не зафиксировано'}
</p>
{ch.lastSnapshot?.recordedAt && (
<p className="text-[9px] text-slate-400 mt-0.5">На {new Date(ch.lastSnapshot.recordedAt).toLocaleDateString('ru-RU')}</p>
)}
<div className="mt-3 flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => handleOpenSnapshot(ch)}
className="px-2.5 py-1 bg-primary-50 text-primary-600 rounded-lg text-[9px] font-bold uppercase hover:bg-primary-100"
>
Зафиксировать подписчиков
</button>
<button type="button" onClick={() => handleDeleteChannel(ch.id)} className="p-1 text-slate-400 hover:text-red-600 rounded" title="Удалить канал">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Отложенные посты */}
<div className="bg-white rounded-[2rem] border border-slate-200 shadow-sm overflow-hidden">
<div className="p-6 border-b border-slate-100">
<div className="flex items-center justify-between">
<h4 className="font-black text-slate-800 text-[10px] uppercase tracking-widest flex items-center gap-2">
<Clock className="w-4 h-4 text-primary-500" /> Отложенные посты
</h4>
</div>
</div>
<div className="p-6">
<PublicationSchedule />
</div>
</div>
{/* Рекомендации для УК */}
<div className="bg-white rounded-[2rem] border border-slate-200 shadow-sm overflow-hidden">
<button
type="button"
onClick={() => setRecommendationsOpen((v) => !v)}
className="w-full flex items-center justify-between p-6 text-left"
>
<h4 className="font-black text-slate-800 text-[10px] uppercase tracking-widest flex items-center gap-2">
<Lightbulb className="w-4 h-4 text-primary-500" /> Рекомендации для УК
</h4>
{recommendationsOpen ? <ChevronUp className="w-5 h-5 text-slate-400" /> : <ChevronDown className="w-5 h-5 text-slate-400" />}
</button>
{recommendationsOpen && (
<ul className="px-6 pb-6 space-y-2 border-t border-slate-100 pt-4">
{UK_RECOMMENDATIONS.map((text, i) => (
<li key={i} className="text-sm text-slate-600 flex gap-2">
<span className="text-primary-500 font-bold"></span>
<span>{text}</span>
</li>
))}
</ul>
)}
</div>
{/* Привлечение */}
<div className="bg-white rounded-[2rem] p-6 border border-slate-200 shadow-sm">
<div className="flex justify-between items-center mb-4">
<h4 className="font-black text-slate-800 text-[10px] uppercase tracking-widest flex items-center gap-2">
<UserPlus className="w-4 h-4 text-primary-500" /> Привлечение
</h4>
<button
type="button"
onClick={handleOpenAttractionCreate}
className="flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 text-white rounded-xl text-[10px] font-bold uppercase tracking-wider hover:bg-primary-700"
>
<Plus className="w-3.5 h-3.5" /> Добавить
</button>
</div>
{attractionLoading ? (
<div className="py-4 text-center text-slate-400 text-xs">Загрузка...</div>
) : attractionList.length === 0 ? (
<div className="py-4 text-center text-slate-400 text-xs">
<p className="mb-2">Нет записей о привлечении</p>
<p className="text-[10px] text-slate-500 mb-3">Добавьте, например: «Наша группа разослала рассылкой жителям», «Провели мероприятие в доме»</p>
<button type="button" onClick={handleOpenAttractionCreate} className="px-4 py-2 bg-primary-600 text-white rounded-xl text-xs font-bold">Добавить</button>
</div>
) : (
<>
{attractionList.reduce((sum, a) => sum + (a.newSubscribersAttributed ?? 0), 0) > 0 && (
<div className="p-3 bg-indigo-50 rounded-xl border border-indigo-100 mb-4">
<p className="text-[10px] font-bold uppercase text-indigo-600 tracking-widest">Прирост подписчиков по действиям</p>
<p className="text-xl font-black text-indigo-800">{attractionList.reduce((sum, a) => sum + (a.newSubscribersAttributed ?? 0), 0).toLocaleString('ru-RU')}</p>
</div>
)}
<div className="space-y-2">
{attractionList.map((item) => {
const TypeIcon = ACTION_TYPE_ICONS[item.actionType];
return (
<div
key={item.id}
className="bg-slate-50 p-3 rounded-xl border border-slate-100 flex flex-col sm:flex-row sm:items-center gap-2"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<TypeIcon className="w-3.5 h-3.5 text-primary-500 shrink-0" />
<span className="font-bold text-slate-800 text-xs">{item.title}</span>
<span className="text-[9px] font-black uppercase text-slate-400 bg-slate-200 px-1.5 py-0.5 rounded">
{ACTION_TYPE_LABELS[item.actionType]}
</span>
</div>
{item.description && <p className="text-[10px] text-slate-600 mt-1 line-clamp-1">{item.description}</p>}
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1 text-[9px] text-slate-500">
<span>{new Date(item.actionDate).toLocaleDateString('ru-RU')}</span>
{item.channelName && <span>{item.channelName}</span>}
{item.eventId != null && (
<span className="font-bold text-primary-600" title={`Мероприятие #${item.eventId}`}>Мероприятие #{item.eventId}</span>
)}
{item.newSubscribersAttributed != null && item.newSubscribersAttributed > 0 && (
<span className="font-bold text-emerald-600">+{item.newSubscribersAttributed} подписчиков</span>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button type="button" onClick={() => handleOpenAttractionEdit(item)} className="p-1.5 text-slate-400 hover:text-primary-600 rounded" title="Изменить">
<Pencil className="w-3.5 h-3.5" />
</button>
<button type="button" onClick={() => handleDeleteAttraction(item.id)} className="p-1.5 text-slate-400 hover:text-red-600 rounded" title="Удалить">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
</>
)}
</div>
{/* Content Ideas Feed */}
<div className="bg-white rounded-[2rem] p-6 border border-slate-200 shadow-sm">
<h4 className="font-black text-slate-800 text-[10px] uppercase tracking-widest mb-6 flex items-center gap-2">
<Layout className="w-4 h-4 text-primary-500" /> Что обсудить сегодня? (Инсайты)
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{CONTENT_IDEAS.map((idea, i) => (
<IdeaCard key={i} {...idea} />
))}
</div>
</div>
{/* Snapshot modal */}
{snapshotModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => !snapshotSaving && setSnapshotModal(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={() => !snapshotSaving && setSnapshotModal(null)} className="p-1 text-slate-400 hover:text-slate-600"><X className="w-5 h-5" /></button>
</div>
<p className="text-sm text-slate-600 mb-4">{snapshotModal.channel.name}</p>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Количество подписчиков</label>
<input
type="number"
min={0}
value={snapshotCount}
onChange={(e) => setSnapshotCount(e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm mb-4"
/>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Заметка (необязательно)</label>
<input
type="text"
value={snapshotNote}
onChange={(e) => setSnapshotNote(e.target.value)}
placeholder="Напр.: после рассылки жителям"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm mb-6"
/>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => !snapshotSaving && setSnapshotModal(null)} className="px-4 py-2 text-slate-600 font-bold text-sm">Отмена</button>
<button type="button" onClick={handleSaveSnapshot} disabled={snapshotSaving || snapshotCount.trim() === ''} className="px-4 py-2 bg-primary-600 text-white rounded-xl font-bold text-sm disabled:opacity-50">
{snapshotSaving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
</div>
)}
{/* Add channel modal */}
{addChannelModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => !newChannelSaving && setAddChannelModal(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">Добавить канал</h3>
<button type="button" onClick={() => !newChannelSaving && setAddChannelModal(false)} className="p-1 text-slate-400 hover:text-slate-600"><X className="w-5 h-5" /></button>
</div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Название</label>
<input
type="text"
value={newChannelName}
onChange={(e) => setNewChannelName(e.target.value)}
placeholder="Напр.: Группа ВК ЖК Мирный"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm mb-4"
/>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Тип</label>
<select value={newChannelType} onChange={(e) => setNewChannelType(e.target.value as 'tg' | 'vk' | 'wa' | 'other')} className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm mb-6">
<option value="tg">Telegram</option>
<option value="vk">ВКонтакте</option>
<option value="wa">WhatsApp</option>
<option value="other">Другое</option>
</select>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => !newChannelSaving && setAddChannelModal(false)} className="px-4 py-2 text-slate-600 font-bold text-sm">Отмена</button>
<button type="button" onClick={handleAddChannel} disabled={newChannelSaving || !newChannelName.trim()} className="px-4 py-2 bg-primary-600 text-white rounded-xl font-bold text-sm disabled:opacity-50">
{newChannelSaving ? 'Сохранение...' : 'Добавить'}
</button>
</div>
</div>
</div>
)}
{/* View/Approve/Reject Post Modal */}
{viewPostModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => !postActionLoading && 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={() => !postActionLoading && 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>
<div className="bg-slate-50 rounded-xl p-4 text-sm text-slate-700 whitespace-pre-wrap">{viewPostModal.content}</div>
</div>
{viewPostModal.editedContent && (
<div>
<p className="text-[10px] font-bold uppercase text-amber-600 mb-1">Отредактированная версия</p>
<div className="bg-amber-50 rounded-xl p-4 text-sm text-slate-700 whitespace-pre-wrap border border-amber-200">{viewPostModal.editedContent}</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>
<span className="text-[10px] text-slate-500">
{new Date(viewPostModal.scheduledAt).toLocaleString('ru-RU')}
</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 === '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={postActionLoading}
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 && reason.trim()) {
setRejectReason(reason.trim());
backendApi.rejectScheduledPost(viewPostModal.id, { rejectionReason: reason.trim() })
.then(() => { setViewPostModal(null); setRejectReason(''); loadScheduledPosts(); })
.finally(() => setPostActionLoading(false));
setPostActionLoading(true);
}
}}
disabled={postActionLoading}
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={postActionLoading}
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>
)}
{/* Create Post Modal */}
{createPostModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => !postFormSaving && setCreatePostModal(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">Создать отложенный пост</h3>
<button type="button" onClick={() => !postFormSaving && setCreatePostModal(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>
<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">
{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>
<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>
<select
value={postForm.topicId === '' ? '' : postForm.topicId}
onChange={(e) => {
const topicId = e.target.value === '' ? '' : Number(e.target.value);
const topic = approvedTopics.find(t => t.id === Number(e.target.value));
setPostForm((f) => ({
...f,
topicId,
title: topic ? topic.title : f.title,
scheduledAt: topic && topic.scheduledDate ? new Date(topic.scheduledDate).toISOString().slice(0, 16) : f.scheduledAt
}));
}}
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm"
>
<option value=""> не выбрана </option>
{approvedTopics.map((t) => (
<option key={t.id} value={t.id}>{t.title} ({t.scheduledDate ? new Date(t.scheduledDate).toLocaleDateString('ru-RU') : ''})</option>
))}
</select>
<p className="text-[9px] text-slate-400 mt-1">Выберите тему из одобренного графика публикации заголовок и дата заполнятся автоматически</p>
</div>
</div>
<div className="flex gap-2 justify-end mt-6">
<button type="button" onClick={() => !postFormSaving && setCreatePostModal(false)} className="px-4 py-2 text-slate-600 font-bold text-sm">Отмена</button>
<button
type="button"
onClick={handleSavePost}
disabled={postFormSaving || !postForm.title.trim() || !postForm.content.trim() || !postForm.scheduledAt || postForm.channelIds.length === 0}
className="px-4 py-2 bg-primary-600 text-white rounded-xl font-bold text-sm disabled:opacity-50"
>
{postFormSaving ? 'Сохранение...' : 'Отправить на согласование'}
</button>
</div>
</div>
</div>
)}
{/* Attraction Modal */}
{attractionModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => !attractionSaving && setAttractionModal(false)}>
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6 max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-center mb-4">
<h3 className="font-black text-slate-800">{attractionEditing ? 'Редактировать действие привлечения' : 'Добавить действие привлечения'}</h3>
<button type="button" onClick={() => !attractionSaving && setAttractionModal(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={attractionForm.title}
onChange={(e) => setAttractionForm((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={attractionForm.description}
onChange={(e) => setAttractionForm((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={2}
/>
</div>
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Тип</label>
<select
value={attractionForm.actionType}
onChange={(e) => {
const v = e.target.value as AttractionAction['actionType'];
setAttractionForm((f) => ({ ...f, actionType: v, eventId: v !== 'event' ? '' : f.eventId }));
}}
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm"
>
<option value="mailing">Рассылка</option>
<option value="event">Мероприятие</option>
<option value="post">Пост</option>
<option value="other">Другое</option>
</select>
</div>
{attractionForm.actionType === 'event' && (
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Мероприятие (необязательно)</label>
<select
value={attractionForm.eventId === '' ? '' : attractionForm.eventId}
onChange={(e) => {
const val = e.target.value === '' ? '' : Number(e.target.value);
const ev = val ? prEvents.find(p => p.id === val) : null;
setAttractionForm((f) => ({
...f,
eventId: val,
title: ev ? ev.title : f.title,
actionDate: ev ? ev.date.slice(0, 10) : f.actionDate,
}));
}}
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm"
>
<option value=""> не выбрано </option>
{prEvents.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title} ({ev.date})</option>
))}
</select>
</div>
)}
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Канал (необязательно)</label>
<select
value={attractionForm.channelId === '' ? '' : attractionForm.channelId}
onChange={(e) => setAttractionForm((f) => ({ ...f, channelId: e.target.value === '' ? '' : Number(e.target.value) }))}
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm"
>
<option value=""> не выбран </option>
{channels.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Дата</label>
<input
type="date"
value={attractionForm.actionDate}
onChange={(e) => setAttractionForm((f) => ({ ...f, actionDate: 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>
<input
type="number"
min={0}
value={attractionForm.newSubscribersAttributed === '' ? '' : attractionForm.newSubscribersAttributed}
onChange={(e) => setAttractionForm((f) => ({ ...f, newSubscribersAttributed: e.target.value === '' ? '' : parseInt(e.target.value, 10) }))}
placeholder="0"
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={() => !attractionSaving && setAttractionModal(false)} className="px-4 py-2 text-slate-600 font-bold text-sm">Отмена</button>
<button type="button" onClick={handleSaveAttraction} disabled={attractionSaving || !attractionForm.title.trim()} className="px-4 py-2 bg-primary-600 text-white rounded-xl font-bold text-sm disabled:opacity-50">
{attractionSaving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
</div>
)}
</div>
);
};
const IdeaCard: React.FC<{ tag: string; title: string; desc: string }> = ({ tag, title, desc }) => (
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100 hover:border-primary-200 transition-all cursor-pointer">
<span className="text-[8px] font-black bg-primary-100 text-primary-600 px-1.5 py-0.5 rounded uppercase tracking-widest">{tag}</span>
<h5 className="font-bold text-slate-800 text-sm mt-2 mb-1">{title}</h5>
<p className="text-[10px] text-slate-500 leading-relaxed italic">{desc}</p>
</div>
);