Files
mkd/components/pr/EventsRegistry.tsx

919 lines
45 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
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>
);
};