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

919 lines
45 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { 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<PREvent['category'], string> = {
holiday: 'Праздник',
eco: 'Эко',
sport: 'Спорт',
training: 'Обучение',
meeting: 'Встреча'
};
const STATUS_LABELS: Record<PREvent['status'], string> = {
planned: 'Запланировано',
in_progress: 'В процессе',
completed: 'Проведено',
canceled: 'Отменено'
};
const CACHE_KEY = 'mkd_pr_events_cache';
interface EventsRegistryProps {
onNavigate?: (tab: string) => void;
}
export const EventsRegistry: React.FC<EventsRegistryProps> = ({ onNavigate }) => {
const cached = readCache<PREvent[]>(CACHE_KEY, []);
const [events, setEvents] = useState<PREvent[]>(cached);
const [loading, setLoading] = useState(cached.length === 0);
const [formOpen, setFormOpen] = useState(false);
const [detailEvent, setDetailEvent] = useState<PREvent | null>(null);
const [editingEvent, setEditingEvent] = useState<PREvent | null>(null);
const [buildings, setBuildings] = useState<Building[]>([]);
const [employees, setEmployees] = useState<Employee[]>([]);
const [districts, setDistricts] = useState<District[]>([]);
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<string, PREvent[]> = {};
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<PREvent> & { 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 (
<div className="space-y-6 animate-fade-in">
<div className="flex justify-between items-center px-1 flex-wrap gap-3">
<div className="flex items-center gap-3">
<h3 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em]">План мероприятий</h3>
<div className="flex rounded-xl overflow-hidden border border-slate-200">
<button
type="button"
onClick={() => setViewMode('list')}
className={`px-3 py-1.5 text-[10px] font-bold ${viewMode === 'list' ? 'bg-primary-600 text-white' : 'bg-white text-slate-500 hover:bg-slate-50'}`}
>
<List className="w-3.5 h-3.5 inline mr-1"/> Список
</button>
<button
type="button"
onClick={() => setViewMode('calendar')}
className={`px-3 py-1.5 text-[10px] font-bold ${viewMode === 'calendar' ? 'bg-primary-600 text-white' : 'bg-white text-slate-500 hover:bg-slate-50'}`}
>
<LayoutGrid className="w-3.5 h-3.5 inline mr-1"/> Календарь
</button>
</div>
{(plannedCount > 0 || completedCount > 0) && (
<div className="flex gap-2 text-[10px]">
<span className="text-slate-500 font-bold">Запланировано: <span className="text-primary-600 font-black">{plannedCount}</span></span>
<span className="text-slate-400">|</span>
<span className="text-slate-500 font-bold">Проведено: <span className="text-emerald-600 font-black">{completedCount}</span></span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleExportCSV}
disabled={events.length === 0}
className="px-3 py-2 border border-slate-200 rounded-xl text-[10px] font-bold text-slate-600 hover:bg-slate-50 disabled:opacity-50 flex items-center gap-1.5"
>
<Download className="w-4 h-4"/> CSV
</button>
<button
onClick={handleCreate}
className="bg-primary-600 text-white px-4 py-2 rounded-xl text-[10px] font-black uppercase flex items-center gap-2 shadow-lg shadow-primary-500/20 active:scale-95 transition-all"
>
<Plus className="w-4 h-4"/> Новое событие
</button>
</div>
</div>
{/* Фильтры */}
<div className="bg-white rounded-2xl border border-slate-200 p-4">
<button
type="button"
onClick={() => setFiltersExpanded(!filtersExpanded)}
className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-slate-600 hover:text-slate-800"
>
<Filter className="w-4 h-4" />
Фильтры {hasActiveFilters && `(${Object.values(filters).filter(Boolean).length})`}
</button>
{filtersExpanded && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3 mt-4">
<div>
<label className="block text-[10px] font-bold text-slate-500 mb-1">Статус</label>
<select
value={filters.status ?? ''}
onChange={e => setFilters(f => ({ ...f, status: e.target.value || undefined }))}
className="w-full border border-slate-200 rounded-lg px-2 py-1.5 text-sm"
>
<option value=""> Все </option>
{Object.entries(STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 mb-1">Тип</label>
<select
value={filters.type ?? ''}
onChange={e => setFilters(f => ({ ...f, type: e.target.value || undefined }))}
className="w-full border border-slate-200 rounded-lg px-2 py-1.5 text-sm"
>
<option value=""> Все </option>
<option value="resident">Жители</option>
<option value="internal">Внутреннее</option>
</select>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 mb-1">Дата с</label>
<input
type="date"
value={filters.from ?? ''}
onChange={e => setFilters(f => ({ ...f, from: e.target.value || undefined }))}
className="w-full border border-slate-200 rounded-lg px-2 py-1.5 text-sm"
/>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 mb-1">Дата по</label>
<input
type="date"
value={filters.to ?? ''}
onChange={e => setFilters(f => ({ ...f, to: e.target.value || undefined }))}
className="w-full border border-slate-200 rounded-lg px-2 py-1.5 text-sm"
/>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 mb-1">Дом</label>
<select
value={filters.buildingId ?? ''}
onChange={e => setFilters(f => ({ ...f, buildingId: e.target.value || undefined }))}
className="w-full border border-slate-200 rounded-lg px-2 py-1.5 text-sm"
>
<option value=""> Все </option>
{buildings.map(b => (
<option key={b.id} value={b.id}>{(b as any).data?.passport?.address || b.id}</option>
))}
</select>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 mb-1">Участок</label>
<select
value={filters.districtId ?? ''}
onChange={e => setFilters(f => ({ ...f, districtId: e.target.value || undefined }))}
className="w-full border border-slate-200 rounded-lg px-2 py-1.5 text-sm"
>
<option value=""> Все </option>
{districts.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
{hasActiveFilters && (
<div className="flex items-end">
<button type="button" onClick={clearFilters} className="px-3 py-1.5 text-[10px] font-bold text-slate-500 hover:text-slate-700 border border-slate-200 rounded-lg">
Сбросить
</button>
</div>
)}
</div>
)}
</div>
{loading ? (
<p className="text-slate-500 py-8">Загрузка...</p>
) : viewMode === 'calendar' ? (
<div className="space-y-4">
{Object.keys(eventsByDate).sort().map(date => (
<div key={date} className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="bg-slate-50 px-4 py-2 border-b border-slate-200">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary-500" />
<span className="font-bold text-slate-800">{new Date(date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', weekday: 'short' })}</span>
</div>
</div>
<div className="p-4 space-y-2">
{eventsByDate[date].map(event => (
<EventCard key={String(event.id)} event={event} onOpen={() => setDetailEvent(event)} onStatusChange={(s) => handleStatusChange(event, s)} />
))}
</div>
</div>
))}
</div>
) : (
<div className="space-y-4">
{displayedEvents.map(event => (
<EventCard
key={String(event.id)}
event={event}
onOpen={() => setDetailEvent(event)}
onStatusChange={(s) => handleStatusChange(event, s)}
/>
))}
{hasMore && (
<button
type="button"
onClick={() => setDisplayLimit(l => l + 20)}
className="w-full py-3 text-[10px] font-bold uppercase text-slate-500 hover:text-primary-600 border border-dashed border-slate-200 rounded-2xl hover:border-primary-200"
>
Показать ещё ({events.length - displayLimit})
</button>
)}
</div>
)}
{events.length === 0 && !loading && (
<div className="bg-slate-100 rounded-[2.5rem] p-10 border-2 border-dashed border-slate-200 flex flex-col items-center justify-center text-slate-400">
<Users className="w-10 h-10 mb-2 opacity-20"/>
<p className="text-xs font-black uppercase tracking-widest">Нет мероприятий</p>
<p className="text-[10px] mt-1">Создайте первое мероприятие</p>
</div>
)}
{formOpen && (
<EventFormModal
event={editingEvent}
buildings={buildings}
districts={districts}
employees={employees}
onSave={handleSaveForm}
onClose={() => { setFormOpen(false); setEditingEvent(null); }}
/>
)}
{detailEvent && (
<EventDetailModal
event={detailEvent}
buildings={buildings}
districts={districts}
employees={employees}
onClose={() => 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))}
/>
)}
</div>
);
};
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 (
<div className="bg-white p-5 rounded-[2.5rem] border border-slate-200 shadow-sm hover:shadow-md hover:border-primary-300 transition-all flex flex-col md:flex-row gap-6 relative group">
<div className={`absolute left-0 top-0 bottom-0 w-1.5 rounded-l-full ${isResident ? 'bg-primary-500' : 'bg-violet-500'}`} />
<div className="flex items-center gap-4 flex-1">
<div className={`w-16 h-16 rounded-3xl flex items-center justify-center ${isResident ? 'bg-primary-50 text-primary-600' : 'bg-violet-50 text-violet-600'}`}>
<CategoryIcon className="w-8 h-8"/>
</div>
<div>
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`text-[9px] font-black px-2 py-0.5 rounded-full uppercase tracking-tighter ${isResident ? 'bg-primary-100 text-primary-600' : 'bg-violet-100 text-violet-600'}`}>
{isResident ? 'Жители' : 'Команда'}
</span>
<span className="text-[10px] text-slate-400 font-bold uppercase">{dateStr}</span>
{onStatusChange ? (
<select
value={event.status || 'planned'}
onChange={e => onStatusChange(e.target.value as PREvent['status'])}
onClick={e => e.stopPropagation()}
className="text-[9px] font-bold px-1.5 py-0.5 rounded border border-slate-200 bg-white text-slate-600 cursor-pointer"
>
{Object.entries(STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
) : event.status ? (
<span className="text-[9px] text-slate-500">{STATUS_LABELS[event.status]}</span>
) : null}
</div>
<h4 className="font-black text-slate-800 text-lg leading-tight group-hover:text-primary-600 transition-colors">{event.title}</h4>
<div className="flex items-center gap-4 mt-2">
<div className="flex items-center gap-1.5 text-[10px] text-slate-500 font-medium">
<MapPin className="w-3.5 h-3.5 text-slate-400"/>
{event.locationPlaceType === 'district' && event.locationDistrictId
? `Участок`
: event.locationPlaceType === 'buildings' && event.locationBuildingIds?.length
? `Дома (${event.locationBuildingIds.length})`
: (event.location || '—')}
</div>
<div className="flex items-center gap-1.5 text-[10px] text-slate-500 font-medium">
<Users className="w-3.5 h-3.5 text-slate-400"/> {event.attendeesCount ?? 0} чел.
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between md:justify-end gap-3 border-t md:border-t-0 border-slate-50 pt-4 md:pt-0">
<div className="text-right">
<p className="text-[9px] text-slate-400 font-bold uppercase mb-1">Бюджет</p>
<p className="text-sm font-black text-slate-900">{event.budget != null ? Number(event.budget).toLocaleString() : '—'} </p>
</div>
<button onClick={onOpen} className="p-3 bg-slate-50 text-slate-300 rounded-2xl hover:text-primary-600 transition-colors">
<ChevronRight className="w-5 h-5"/>
</button>
</div>
</div>
);
};
interface EventFormModalProps {
event: PREvent | null;
buildings: Building[];
districts: District[];
employees: Employee[];
onSave: (payload: Partial<PREvent> & { title: string; date: string; type: PREvent['type']; category: PREvent['category']; assignedEmployeeIds?: string[] }) => Promise<void>;
onClose: () => void;
}
const EventFormModal: React.FC<EventFormModalProps> = ({ event, buildings, districts, employees, onSave, onClose }) => {
const [title, setTitle] = useState(event?.title ?? '');
const [date, setDate] = useState(event?.date ?? '');
const [type, setType] = useState<PREvent['type']>(event?.type ?? 'resident');
const [category, setCategory] = useState<PREvent['category']>(event?.category ?? 'holiday');
const [status, setStatus] = useState<PREvent['status']>(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<string[]>(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<string[]>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-200 flex justify-between items-center">
<h3 className="font-black text-slate-800">{event ? 'Редактировать мероприятие' : 'Новое мероприятие'}</h3>
<button type="button" onClick={onClose} className="p-2 text-slate-400 hover:text-slate-600"><X className="w-5 h-5"/></button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Название *</label>
<input type="text" value={title} onChange={e => setTitle(e.target.value)} className="w-full border border-slate-300 rounded-lg px-3 py-2" required />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Дата *</label>
<input type="date" value={date} onChange={e => setDate(e.target.value)} className="w-full border border-slate-300 rounded-lg px-3 py-2" required />
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Тип</label>
<select value={type} onChange={e => setType(e.target.value as PREvent['type'])} className="w-full border border-slate-300 rounded-lg px-3 py-2">
<option value="resident">Жители</option>
<option value="internal">Внутреннее</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Категория</label>
<select value={category} onChange={e => setCategory(e.target.value as PREvent['category'])} className="w-full border border-slate-300 rounded-lg px-3 py-2">
{Object.entries(CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Статус</label>
<select value={status} onChange={e => setStatus(e.target.value as PREvent['status'])} className="w-full border border-slate-300 rounded-lg px-3 py-2">
{Object.entries(STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
{type === 'resident' && (
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Место проведения</label>
<select
value={locationPlaceType}
onChange={e => {
const v = e.target.value as 'district' | 'buildings' | '';
setLocationPlaceType(v);
if (v !== 'district') setLocationDistrictId('');
if (v !== 'buildings') setLocationBuildingIds([]);
}}
className="w-full border border-slate-300 rounded-lg px-3 py-2"
>
<option value=""> Выберите </option>
<option value="district">Участок</option>
<option value="buildings">Дома отдельно</option>
</select>
</div>
{locationPlaceType === 'district' && (
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Участок *</label>
<select value={locationDistrictId} onChange={e => setLocationDistrictId(e.target.value)} className="w-full border border-slate-300 rounded-lg px-3 py-2">
<option value=""> Выберите участок </option>
{districts.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
)}
{locationPlaceType === 'buildings' && (
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Дома *</label>
<div className="border border-slate-300 rounded-lg p-3 max-h-40 overflow-y-auto space-y-1">
{buildings.map(b => (
<label key={b.id} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={locationBuildingIds.includes(b.id)} onChange={() => toggleBuilding(b.id)} />
<span className="text-sm">{(b as any).data?.passport?.address || b.id}</span>
</label>
))}
{buildings.length === 0 && <p className="text-slate-500 text-sm">Нет домов</p>}
</div>
</div>
)}
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Адрес (по желанию)</label>
<input type="text" value={location} onChange={e => setLocation(e.target.value)} className="w-full border border-slate-300 rounded-lg px-3 py-2" placeholder="Например: двор, клубная комната" />
</div>
</div>
)}
{type === 'internal' && (
<>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Место проведения</label>
<input type="text" value={location} onChange={e => setLocation(e.target.value)} className="w-full border border-slate-300 rounded-lg px-3 py-2" placeholder="Адрес или «Офис УК»" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Привязка</label>
<select value={locationType} onChange={e => { setLocationType(e.target.value as 'building' | 'office' | ''); if (e.target.value !== 'building') setLocationBuildingId(''); }} className="w-full border border-slate-300 rounded-lg px-3 py-2">
<option value=""></option>
<option value="building">Дом</option>
<option value="office">Офис</option>
</select>
</div>
{locationType === 'building' && (
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Дом</label>
<select value={locationBuildingId} onChange={e => setLocationBuildingId(e.target.value)} className="w-full border border-slate-300 rounded-lg px-3 py-2">
<option value=""></option>
{buildings.map(b => (
<option key={b.id} value={b.id}>{(b as any).data?.passport?.address || b.id}</option>
))}
</select>
</div>
)}
</div>
</>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Участников</label>
<input type="number" min={0} value={attendeesCount} onChange={e => setAttendeesCount(Number(e.target.value) || 0)} className="w-full border border-slate-300 rounded-lg px-3 py-2" />
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Бюджет ()</label>
<input type="number" min={0} step={0.01} value={budget} onChange={e => setBudget(e.target.value)} className="w-full border border-slate-300 rounded-lg px-3 py-2" />
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Краткий план</label>
<textarea value={shortPlan} onChange={e => setShortPlan(e.target.value)} rows={3} className="w-full border border-slate-300 rounded-lg px-3 py-2" placeholder="Цель, этапы, сроки" />
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Текст объявления</label>
<textarea value={announcement} onChange={e => setAnnouncement(e.target.value)} rows={3} className="w-full border border-slate-300 rounded-lg px-3 py-2" placeholder="Анонс для жителей/команды" />
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Сотрудники для помощи</label>
<div className="border border-slate-300 rounded-lg p-3 max-h-32 overflow-y-auto space-y-1">
{employees.map(emp => (
<label key={emp.id} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={assignedEmployeeIds.includes(emp.id)} onChange={() => toggleEmployee(emp.id)} />
<span className="text-sm">{emp.name} {emp.position ? `${emp.position}` : ''}</span>
</label>
))}
{employees.length === 0 && <p className="text-slate-500 text-sm">Нет сотрудников</p>}
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<button type="button" onClick={onClose} className="px-4 py-2 border border-slate-300 rounded-lg text-slate-700">Отмена</button>
<button type="submit" disabled={saving} className="px-4 py-2 bg-primary-600 text-white rounded-lg disabled:opacity-50">Сохранить</button>
</div>
</form>
</div>
</div>
);
};
interface EventDetailModalProps {
event: PREvent;
buildings: Building[];
districts: District[];
employees: Employee[];
onClose: () => void;
onEdit: () => void;
onDelete: () => void;
onOpenInvoice: () => void;
onNotifySMM: () => void;
onCopy: () => void;
onPhotoUploaded: () => void;
onRefresh: () => void;
}
const EventDetailModal: React.FC<EventDetailModalProps> = ({
event, buildings, districts, employees, onClose, onEdit, onDelete, onOpenInvoice, onNotifySMM, onCopy, onPhotoUploaded, onRefresh
}) => {
const [photos, setPhotos] = useState<PREventPhoto[]>(event.photos ?? []);
const [uploading, setUploading] = useState(false);
const [uploadLocationType, setUploadLocationType] = useState<'building' | 'office'>('office');
const [uploadBuildingId, setUploadBuildingId] = useState('');
const [uploadCaption, setUploadCaption] = useState('');
useEffect(() => {
setPhotos(event.photos ?? []);
}, [event.id, event.photos]);
const assigneeNames = (event.assignedEmployeeIds ?? [])
.map(id => employees.find(e => e.id === id)?.name)
.filter(Boolean) as string[];
const handleUploadPhoto = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !event.id) return;
setUploading(true);
try {
const fd = new FormData();
fd.append('photo', file);
fd.append('locationType', uploadLocationType);
if (uploadLocationType === 'building' && uploadBuildingId) fd.append('locationBuildingId', uploadBuildingId);
if (uploadCaption.trim()) fd.append('caption', uploadCaption.trim());
const photo = await backendApi.uploadPREventPhoto(event.id, fd);
setPhotos(prev => [...prev, photo]);
setUploadCaption('');
onPhotoUploaded();
} catch (err) {
console.error(err);
} finally {
setUploading(false);
e.target.value = '';
}
};
const handleDeletePhoto = async (photoId: number) => {
try {
await backendApi.deletePREventPhoto(event.id, photoId);
setPhotos(prev => prev.filter(p => p.id !== photoId));
onPhotoUploaded();
} catch (err) {
console.error(err);
}
};
const dateStr = typeof event.date === 'string' ? event.date : String(event.date);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-200 flex justify-between items-center">
<h3 className="font-black text-slate-800">{event.title}</h3>
<button type="button" onClick={onClose} className="p-2 text-slate-400 hover:text-slate-600"><X className="w-5 h-5"/></button>
</div>
<div className="p-6 space-y-6">
<div className="flex flex-wrap gap-2">
<span className="text-[10px] font-bold text-slate-500">{dateStr}</span>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-slate-100">{CATEGORY_LABELS[event.category]}</span>
<span className="text-[10px] text-slate-500">{STATUS_LABELS[event.status]}</span>
{event.locationPlaceType === 'district' && event.locationDistrictId && (
<span className="text-[10px] text-slate-500 flex items-center gap-1">
<MapPin className="w-3 h-3"/> Участок: {districts.find(d => d.id === event.locationDistrictId)?.name || event.locationDistrictId}
</span>
)}
{event.locationPlaceType === 'buildings' && event.locationBuildingIds?.length ? (
<span className="text-[10px] text-slate-500 flex items-center gap-1">
<MapPin className="w-3 h-3"/> Дома: {event.locationBuildingIds.length} шт.
</span>
) : event.location && (
<span className="text-[10px] text-slate-500 flex items-center gap-1"><MapPin className="w-3 h-3"/>{event.location}</span>
)}
</div>
{event.shortPlan && (
<div>
<p className="text-xs font-bold text-slate-600 mb-1 flex items-center gap-1"><FileText className="w-4 h-4"/> Краткий план</p>
<p className="text-sm text-slate-700 whitespace-pre-wrap">{event.shortPlan}</p>
</div>
)}
{event.announcement && (
<div>
<p className="text-xs font-bold text-slate-600 mb-1 flex items-center gap-1"><Megaphone className="w-4 h-4"/> Объявление</p>
<p className="text-sm text-slate-700 whitespace-pre-wrap">{event.announcement}</p>
</div>
)}
{assigneeNames.length > 0 && (
<div>
<p className="text-xs font-bold text-slate-600 mb-1 flex items-center gap-1"><UserPlus className="w-4 h-4"/> Сотрудники для помощи</p>
<p className="text-sm text-slate-700">{assigneeNames.join(', ')}</p>
</div>
)}
{(event.invoices?.length ?? 0) > 0 && (
<div>
<p className="text-xs font-bold text-slate-600 mb-1 flex items-center gap-1"><Banknote className="w-4 h-4"/> Счета</p>
<ul className="text-sm space-y-1">
{event.invoices!.map(inv => (
<li key={inv.id}>
<button
type="button"
className="text-primary-600 hover:underline"
onClick={() => {
window.dispatchEvent(new CustomEvent('mkd-open-finance-invoice', { detail: { invoiceId: inv.id } }));
onClose();
}}
>
{inv.invoiceNumber} {inv.status}
</button>
</li>
))}
</ul>
</div>
)}
<div>
<p className="text-xs font-bold text-slate-600 mb-2 flex items-center gap-1"><Camera className="w-4 h-4"/> Фотоотчёт</p>
<div className="flex flex-wrap gap-2 mb-2">
{event.type === 'internal' && (
<>
<select value={uploadLocationType} onChange={e => setUploadLocationType(e.target.value as 'building' | 'office')} className="text-sm border rounded px-2 py-1">
<option value="office">Офис</option>
<option value="building">Дом</option>
</select>
{uploadLocationType === 'building' && (
<select value={uploadBuildingId} onChange={e => setUploadBuildingId(e.target.value)} className="text-sm border rounded px-2 py-1">
<option value=""></option>
{buildings.map(b => (
<option key={b.id} value={b.id}>{(b as any).data?.passport?.address || b.id}</option>
))}
</select>
)}
</>
)}
<input
type="text"
value={uploadCaption}
onChange={e => setUploadCaption(e.target.value)}
placeholder="Подпись к фото"
className="text-sm border border-slate-200 rounded-lg px-2 py-1.5 max-w-[140px]"
/>
<label className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-100 rounded-lg text-sm cursor-pointer hover:bg-slate-200">
<input type="file" accept="image/*" className="hidden" onChange={handleUploadPhoto} disabled={uploading} />
{uploading ? 'Загрузка...' : 'Добавить фото'}
</label>
</div>
<div className="grid grid-cols-3 gap-2">
{photos.map(p => (
<div key={p.id} className="relative group">
<img src={p.photoUrl.startsWith('http') ? p.photoUrl : `${UPLOADS_BASE}${p.photoUrl.startsWith('/') ? '' : '/'}${p.photoUrl}`} alt="" className="w-full h-24 object-cover rounded-lg" />
{p.caption && <p className="text-[10px] text-slate-500 truncate">{p.caption}</p>}
<button type="button" onClick={() => handleDeletePhoto(p.id)} className="absolute top-1 right-1 bg-red-500 text-white rounded p-1 opacity-0 group-hover:opacity-100 text-xs">×</button>
</div>
))}
</div>
</div>
<div className="flex flex-wrap gap-2 pt-4 border-t border-slate-200">
<button type="button" onClick={onNotifySMM} className="px-4 py-2 bg-sky-600 text-white rounded-lg text-sm font-bold flex items-center gap-1">
<Megaphone className="w-4 h-4"/> Оповестить в SMM
</button>
<button type="button" onClick={onOpenInvoice} className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm font-bold flex items-center gap-1">
<Banknote className="w-4 h-4"/> Выставить счёт
</button>
<button type="button" onClick={onCopy} className="px-4 py-2 border border-slate-300 rounded-lg text-sm flex items-center gap-1">
<Copy className="w-4 h-4"/> Создать по образцу
</button>
<button type="button" onClick={onEdit} className="px-4 py-2 border border-slate-300 rounded-lg text-sm">Редактировать</button>
<button type="button" onClick={onRefresh} className="px-4 py-2 border border-slate-300 rounded-lg text-sm">Обновить</button>
<button type="button" onClick={onDelete} className="px-4 py-2 text-red-600 border border-red-300 rounded-lg text-sm">Удалить</button>
</div>
</div>
</div>
</div>
);
};