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