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

1016 lines
63 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 {
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>
);