import React, { useState, useEffect } from 'react'; import { PREvent, PREventPhoto, Building, District, Employee } from '../../types'; import { backendApi } from '../../services/apiClient'; import { readCache, saveCache } from '../../hooks/useCachedFetch'; import { REFRESH_EVENTS, EVENT_SMM_POST_FROM_EVENT } from '../../constants/refreshEvents'; import { Calendar, MapPin, Users, Coffee, PartyPopper, TreePine, GraduationCap, ChevronRight, Plus, X, FileText, Megaphone, UserPlus, Camera, Banknote, Filter, List, LayoutGrid, Download, Copy } from 'lucide-react'; const UPLOADS_BASE = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api').replace(/\/api\/?$/, '') || 'http://localhost:4000'; const CATEGORY_LABELS: Record = { holiday: 'Праздник', eco: 'Эко', sport: 'Спорт', training: 'Обучение', meeting: 'Встреча' }; const STATUS_LABELS: Record = { planned: 'Запланировано', in_progress: 'В процессе', completed: 'Проведено', canceled: 'Отменено' }; const CACHE_KEY = 'mkd_pr_events_cache'; interface EventsRegistryProps { onNavigate?: (tab: string) => void; } export const EventsRegistry: React.FC = ({ onNavigate }) => { const cached = readCache(CACHE_KEY, []); const [events, setEvents] = useState(cached); const [loading, setLoading] = useState(cached.length === 0); const [formOpen, setFormOpen] = useState(false); const [detailEvent, setDetailEvent] = useState(null); const [editingEvent, setEditingEvent] = useState(null); const [buildings, setBuildings] = useState([]); const [employees, setEmployees] = useState([]); const [districts, setDistricts] = useState([]); const [filters, setFilters] = useState<{ status?: string; type?: string; buildingId?: string; districtId?: string; from?: string; to?: string }>({}); const [filtersExpanded, setFiltersExpanded] = useState(false); const [viewMode, setViewMode] = useState<'list' | 'calendar'>('list'); const [displayLimit, setDisplayLimit] = useState(20); const fetchEvents = async (showSpinner = true) => { try { if (showSpinner && cached.length === 0) setLoading(true); const list = await backendApi.getPREvents({ limit: 100, ...(filters.status && { status: filters.status }), ...(filters.type && { type: filters.type }), ...(filters.buildingId && { buildingId: filters.buildingId }), ...(filters.districtId && { districtId: filters.districtId }), ...(filters.from && { from: filters.from }), ...(filters.to && { to: filters.to }), }); const arr = Array.isArray(list) ? list : []; setEvents(arr); saveCache(CACHE_KEY, arr); } catch (e) { setEvents([]); } finally { setLoading(false); } }; useEffect(() => { fetchEvents(); }, [filters]); useEffect(() => { const onRefresh = () => fetchEvents(false); window.addEventListener(REFRESH_EVENTS.events, onRefresh); return () => window.removeEventListener(REFRESH_EVENTS.events, onRefresh); }, []); useEffect(() => { const interval = setInterval(() => fetchEvents(false), 10 * 1000); return () => clearInterval(interval); }, []); useEffect(() => { backendApi.getBuildings().then(setBuildings).catch(() => setBuildings([])); backendApi.getDistricts().then(setDistricts).catch(() => setDistricts([])); }, []); useEffect(() => { if (formOpen || detailEvent) { backendApi.getEmployees().then(setEmployees).catch(() => setEmployees([])); } }, [formOpen, detailEvent]); const clearFilters = () => setFilters({}); const hasActiveFilters = Object.values(filters).some(Boolean); const displayedEvents = events.slice(0, displayLimit); const hasMore = events.length > displayLimit; useEffect(() => { setDisplayLimit(20); }, [filters]); const eventsByDate: Record = {}; displayedEvents.forEach(ev => { const d = typeof ev.date === 'string' ? ev.date.slice(0, 10) : String(ev.date).slice(0, 10); if (!eventsByDate[d]) eventsByDate[d] = []; eventsByDate[d].push(ev); }); const plannedCount = events.filter(e => e.status === 'planned' || e.status === 'in_progress').length; const completedCount = events.filter(e => e.status === 'completed').length; const handleExportCSV = () => { const headers = ['Название', 'Дата', 'Тип', 'Категория', 'Статус', 'Место', 'Участников', 'Бюджет (₽)', 'Участок/дома']; const rows = events.map(e => { const locPlace = e.locationPlaceType === 'district' && e.locationDistrictId ? (districts.find(d => d.id === e.locationDistrictId)?.name || 'Участок') : e.locationPlaceType === 'buildings' && e.locationBuildingIds?.length ? `${e.locationBuildingIds.length} домов` : '—'; return [ e.title || '', typeof e.date === 'string' ? e.date : String(e.date), e.type === 'resident' ? 'Жители' : 'Внутреннее', CATEGORY_LABELS[e.category] || '', STATUS_LABELS[e.status] || '', e.location || '—', String(e.attendeesCount ?? 0), e.budget != null ? Number(e.budget).toLocaleString('ru-RU') : '—', locPlace, ]; }); const csvContent = [headers.join(';'), ...rows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(';'))].join('\n'); const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `мероприятия_${new Date().toISOString().split('T')[0]}.csv`; link.click(); URL.revokeObjectURL(link.href); }; const handleCreate = () => { setEditingEvent(null); setFormOpen(true); }; const handleEdit = (event: PREvent) => { setEditingEvent(event); setFormOpen(true); setDetailEvent(null); }; const handleSaveForm = async (payload: Partial & { title: string; date: string; type: PREvent['type']; category: PREvent['category']; assignedEmployeeIds?: string[] }) => { try { if (editingEvent && editingEvent.id != null) { await backendApi.updatePREvent(editingEvent.id, payload); } else { await backendApi.createPREvent(payload); } setFormOpen(false); setEditingEvent(null); window.dispatchEvent(new CustomEvent('mkd-events-changed')); fetchEvents(); } catch (e) { console.error(e); throw e; } }; const handleDelete = async (id: string | number) => { if (!confirm('Удалить мероприятие?')) return; try { await backendApi.deletePREvent(id); setDetailEvent(null); window.dispatchEvent(new CustomEvent('mkd-events-changed')); fetchEvents(); } catch (e) { console.error(e); } }; const handleCopyEvent = (ev: PREvent) => { const { id, ...rest } = ev as PREvent & { id?: unknown }; setEditingEvent({ ...rest, id: undefined as any, title: `${ev.title} (копия)`, status: 'planned', assignedEmployeeIds: ev.assignedEmployeeIds ?? [], } as PREvent); setFormOpen(true); setDetailEvent(null); }; const handleStatusChange = async (ev: PREvent, newStatus: PREvent['status']) => { try { await backendApi.updatePREvent(ev.id, { status: newStatus }); fetchEvents(); if (detailEvent?.id === ev.id) { backendApi.getPREvent(ev.id).then(setDetailEvent).catch(() => setDetailEvent(null)); } } catch (e) { console.error(e); } }; const handleOpenInvoice = (event: PREvent) => { window.dispatchEvent(new CustomEvent('mkd-open-finance-invoice', { detail: { purposeType: 'event', purposeEventId: String(event.id), purposeDescription: event.title, totalAmount: event.budget ?? undefined } })); setDetailEvent(null); }; return (

План мероприятий

{(plannedCount > 0 || completedCount > 0) && (
Запланировано: {plannedCount} | Проведено: {completedCount}
)}
{/* Фильтры */}
{filtersExpanded && (
setFilters(f => ({ ...f, from: e.target.value || undefined }))} className="w-full border border-slate-200 rounded-lg px-2 py-1.5 text-sm" />
setFilters(f => ({ ...f, to: e.target.value || undefined }))} className="w-full border border-slate-200 rounded-lg px-2 py-1.5 text-sm" />
{hasActiveFilters && (
)}
)}
{loading ? (

Загрузка...

) : viewMode === 'calendar' ? (
{Object.keys(eventsByDate).sort().map(date => (
{new Date(date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', weekday: 'short' })}
{eventsByDate[date].map(event => ( setDetailEvent(event)} onStatusChange={(s) => handleStatusChange(event, s)} /> ))}
))}
) : (
{displayedEvents.map(event => ( setDetailEvent(event)} onStatusChange={(s) => handleStatusChange(event, s)} /> ))} {hasMore && ( )}
)} {events.length === 0 && !loading && (

Нет мероприятий

Создайте первое мероприятие

)} {formOpen && ( { setFormOpen(false); setEditingEvent(null); }} /> )} {detailEvent && ( setDetailEvent(null)} onEdit={() => handleEdit(detailEvent)} onDelete={() => handleDelete(detailEvent.id)} onOpenInvoice={() => handleOpenInvoice(detailEvent)} onNotifySMM={() => { onNavigate?.('smm'); setDetailEvent(null); setTimeout(() => { window.dispatchEvent(new CustomEvent(EVENT_SMM_POST_FROM_EVENT, { detail: { event: detailEvent } })); }, 100); }} onCopy={() => handleCopyEvent(detailEvent)} onPhotoUploaded={() => { fetchEvents(); setDetailEvent(prev => prev ? { ...prev } : null); }} onRefresh={() => backendApi.getPREvent(detailEvent.id).then(setDetailEvent).catch(() => setDetailEvent(null))} /> )}
); }; const EventCard: React.FC<{ event: PREvent; onOpen: () => void; onStatusChange?: (status: PREvent['status']) => void }> = ({ event, onOpen, onStatusChange }) => { const isResident = event.type === 'resident'; const CategoryIcon = event.category === 'holiday' ? PartyPopper : event.category === 'eco' ? TreePine : event.category === 'training' ? GraduationCap : Coffee; const dateStr = typeof event.date === 'string' ? event.date : (event.date as unknown as string); return (
{isResident ? 'Жители' : 'Команда'} {dateStr} {onStatusChange ? ( ) : event.status ? ( {STATUS_LABELS[event.status]} ) : null}

{event.title}

{event.locationPlaceType === 'district' && event.locationDistrictId ? `Участок` : event.locationPlaceType === 'buildings' && event.locationBuildingIds?.length ? `Дома (${event.locationBuildingIds.length})` : (event.location || '—')}
{event.attendeesCount ?? 0} чел.

Бюджет

{event.budget != null ? Number(event.budget).toLocaleString() : '—'} ₽

); }; interface EventFormModalProps { event: PREvent | null; buildings: Building[]; districts: District[]; employees: Employee[]; onSave: (payload: Partial & { title: string; date: string; type: PREvent['type']; category: PREvent['category']; assignedEmployeeIds?: string[] }) => Promise; onClose: () => void; } const EventFormModal: React.FC = ({ event, buildings, districts, employees, onSave, onClose }) => { const [title, setTitle] = useState(event?.title ?? ''); const [date, setDate] = useState(event?.date ?? ''); const [type, setType] = useState(event?.type ?? 'resident'); const [category, setCategory] = useState(event?.category ?? 'holiday'); const [status, setStatus] = useState(event?.status ?? 'planned'); const [location, setLocation] = useState(event?.location ?? ''); const [locationType, setLocationType] = useState<'building' | 'office' | ''>(event?.locationType ?? ''); const [locationBuildingId, setLocationBuildingId] = useState(event?.locationBuildingId ?? ''); const [locationPlaceType, setLocationPlaceType] = useState<'district' | 'buildings' | ''>(event?.locationPlaceType ?? ''); const [locationDistrictId, setLocationDistrictId] = useState(event?.locationDistrictId ?? ''); const [locationBuildingIds, setLocationBuildingIds] = useState(event?.locationBuildingIds ?? []); const [attendeesCount, setAttendeesCount] = useState(event?.attendeesCount ?? 0); const [budget, setBudget] = useState(event?.budget ?? ''); const [shortPlan, setShortPlan] = useState(event?.shortPlan ?? ''); const [announcement, setAnnouncement] = useState(event?.announcement ?? ''); const [assignedEmployeeIds, setAssignedEmployeeIds] = useState(event?.assignedEmployeeIds ?? []); const [saving, setSaving] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!title.trim() || !date) return; setSaving(true); try { await onSave({ title: title.trim(), date, type, category, status, location: location.trim() || undefined, locationType: type === 'internal' ? (locationType || undefined) : undefined, locationBuildingId: type === 'internal' ? (locationBuildingId || undefined) : undefined, locationPlaceType: type === 'resident' ? (locationPlaceType || undefined) : undefined, locationDistrictId: type === 'resident' ? (locationDistrictId || undefined) : undefined, locationBuildingIds: type === 'resident' && locationPlaceType === 'buildings' ? locationBuildingIds : undefined, attendeesCount: Number(attendeesCount) || 0, budget: budget === '' ? undefined : Number(budget), shortPlan: shortPlan.trim() || undefined, announcement: announcement.trim() || undefined, assignedEmployeeIds }); onClose(); } catch (_) { setSaving(false); } }; const toggleBuilding = (buildingId: string) => { setLocationBuildingIds(prev => prev.includes(buildingId) ? prev.filter(x => x !== buildingId) : [...prev, buildingId]); }; const toggleEmployee = (id: string) => { setAssignedEmployeeIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]); }; return (
e.stopPropagation()}>

{event ? 'Редактировать мероприятие' : 'Новое мероприятие'}

setTitle(e.target.value)} className="w-full border border-slate-300 rounded-lg px-3 py-2" required />
setDate(e.target.value)} className="w-full border border-slate-300 rounded-lg px-3 py-2" required />
{type === 'resident' && (
{locationPlaceType === 'district' && (
)} {locationPlaceType === 'buildings' && (
{buildings.map(b => ( ))} {buildings.length === 0 &&

Нет домов

}
)}
setLocation(e.target.value)} className="w-full border border-slate-300 rounded-lg px-3 py-2" placeholder="Например: двор, клубная комната" />
)} {type === 'internal' && ( <>
setLocation(e.target.value)} className="w-full border border-slate-300 rounded-lg px-3 py-2" placeholder="Адрес или «Офис УК»" />
{locationType === 'building' && (
)}
)}
setAttendeesCount(Number(e.target.value) || 0)} className="w-full border border-slate-300 rounded-lg px-3 py-2" />
setBudget(e.target.value)} className="w-full border border-slate-300 rounded-lg px-3 py-2" />