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