919 lines
45 KiB
TypeScript
919 lines
45 KiB
TypeScript
|
|
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>
|
|||
|
|
);
|
|||
|
|
};
|