Initial commit MKD fixes

This commit is contained in:
Arsen
2026-02-04 00:17:04 +05:00
commit de94ad707b
312 changed files with 138754 additions and 0 deletions

File diff suppressed because it is too large Load Diff

918
components/pr/EventsRegistry.tsx Executable file
View File

@@ -0,0 +1,918 @@
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>
);
};

367
components/pr/NPSSurveyPage.tsx Executable file
View File

@@ -0,0 +1,367 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Building2, Star, MessageSquare, CheckCircle2, X, Lock } from 'lucide-react';
import { apiClient, getAuthToken, setAuthToken, fetchGuestToken } from '../../services/apiClient';
import { NPSSurvey } from '../../types';
interface NPSSurveyPageProps {
surveyId: string | number;
apartment?: string; // Номер квартиры из параметров URL
}
// Используем тип из types.ts
export const NPSSurveyPage: React.FC<NPSSurveyPageProps> = ({ surveyId, apartment: apartmentProp }) => {
const [survey, setSurvey] = useState<NPSSurvey | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isAuthorized, setIsAuthorized] = useState(false);
const [accessKeyInput, setAccessKeyInput] = useState('');
const [score, setScore] = useState<number | null>(null);
const [comment, setComment] = useState('');
const [respondentName, setRespondentName] = useState('');
const [apartment, setApartment] = useState(apartmentProp || '');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
// Получаем номер квартиры из URL параметров, если не передан в пропсах
useEffect(() => {
if (!apartmentProp) {
const urlParams = new URLSearchParams(window.location.search);
const apartmentFromUrl = urlParams.get('apartment');
if (apartmentFromUrl) {
setApartment(apartmentFromUrl);
}
}
}, [apartmentProp]);
const loadSurvey = useCallback(async () => {
if (!surveyId) {
console.error('Survey ID is missing');
setIsLoading(false);
return;
}
try {
setIsLoading(true);
// Гость без учётки: если нет токена — получаем гостевой (доступ к опросам)
if (!getAuthToken()) {
try {
const guestToken = await fetchGuestToken();
setAuthToken(guestToken);
} catch (e) {
console.error('Failed to get guest token:', e);
}
}
console.log(`Loading survey with ID: ${surveyId}`);
const data = await apiClient.get(`/pr/nps-surveys/${surveyId}`);
console.log('Survey loaded:', data);
setSurvey(data);
} catch (err: any) {
console.error('Error loading survey:', err);
const errorMessage = err.response?.data?.error || err.message || 'Неизвестная ошибка';
console.error(`Failed to load survey ${surveyId}:`, errorMessage);
// Не показываем alert сразу - покажем ошибку в UI
setSurvey(null);
} finally {
// Гарантированно устанавливаем isLoading в false
setIsLoading(false);
}
}, [surveyId]);
useEffect(() => {
if (surveyId) {
loadSurvey();
}
}, [surveyId, loadSurvey]);
useEffect(() => {
if (survey) {
// Проверяем ключ из URL
const urlParams = new URLSearchParams(window.location.search);
const keyFromUrl = urlParams.get('key');
if (keyFromUrl === survey.accessKey) {
setIsAuthorized(true);
} else if (!keyFromUrl) {
// Если ключа нет в URL, но опрос загружен - показываем форму ввода
setIsAuthorized(false);
} else {
// Неверный ключ
setIsAuthorized(false);
}
}
}, [survey]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (score === null) {
alert('Пожалуйста, выберите оценку');
return;
}
try {
setIsSubmitting(true);
await apiClient.post(`/pr/nps-surveys/${surveyId}/responses`, {
score,
comment: comment || null,
respondent_name: respondentName || null,
apartment: apartment || null,
access_key: survey?.accessKey
});
setIsSubmitted(true);
} catch (err: any) {
console.error('Error submitting response:', err);
const errorMessage = err.response?.data?.error || err.message || 'Неизвестная ошибка';
alert(`Ошибка отправки ответа: ${errorMessage}`);
} finally {
setIsSubmitting(false);
}
};
// Форма ввода ключа доступа
if (!isAuthorized && survey) {
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-3xl p-8 shadow-2xl border border-slate-200">
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center w-16 h-16 bg-indigo-100 rounded-full mb-4">
<Lock className="w-8 h-8 text-indigo-600" />
</div>
<h2 className="text-2xl font-black text-slate-800 mb-2">Доступ к опросу</h2>
<p className="text-sm text-slate-600">Введите ключ доступа для участия в опросе</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-700 mb-2 uppercase tracking-wider">
Ключ доступа
</label>
<input
type="password"
value={accessKeyInput}
onChange={(e) => setAccessKeyInput(e.target.value)}
placeholder="Введите ключ доступа"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
onKeyPress={(e) => {
if (e.key === 'Enter' && survey && accessKeyInput === survey.accessKey) {
setIsAuthorized(true);
const url = new URL(window.location.href);
url.searchParams.set('key', accessKeyInput);
window.history.pushState({}, '', url.toString());
}
}}
/>
</div>
<button
onClick={() => {
if (survey && accessKeyInput === survey.accessKey) {
setIsAuthorized(true);
const url = new URL(window.location.href);
url.searchParams.set('key', accessKeyInput);
window.history.pushState({}, '', url.toString());
} else {
alert('Неверный ключ доступа');
setAccessKeyInput('');
}
}}
className="w-full px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
Войти
</button>
</div>
</div>
</div>
);
}
// Страница благодарности после отправки
if (isSubmitted) {
return (
<div className="min-h-screen bg-gradient-to-br from-emerald-50 via-white to-teal-50 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-3xl p-8 shadow-2xl border border-slate-200 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 bg-emerald-100 rounded-full mb-6">
<CheckCircle2 className="w-10 h-10 text-emerald-600" />
</div>
<h2 className="text-2xl font-black text-slate-800 mb-3">Спасибо за ваш отзыв!</h2>
<p className="text-slate-600 mb-6">
Ваше мнение очень важно для нас. Мы используем ваши ответы для улучшения качества обслуживания.
</p>
<div className="pt-6 border-t border-slate-200">
<p className="text-xs text-slate-400">
Ваш ответ сохранен анонимно
</p>
</div>
</div>
</div>
);
}
// Основная страница опроса
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-indigo-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-600">Загрузка опроса...</p>
</div>
</div>
);
}
// Если опрос не загружен и не загружается - показываем ошибку
if (!survey) {
return (
<div className="min-h-screen bg-gradient-to-br from-red-50 via-white to-pink-50 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-3xl p-8 shadow-2xl border border-red-200 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-red-100 rounded-full mb-4">
<X className="w-8 h-8 text-red-600" />
</div>
<h2 className="text-2xl font-black text-slate-800 mb-3">Ошибка загрузки опроса</h2>
<p className="text-slate-600 mb-6">
Не удалось загрузить опрос. Проверьте правильность ссылки или обратитесь к администратору.
</p>
<p className="text-xs text-slate-400 mb-4">
ID опроса: {surveyId}
</p>
<button
onClick={() => window.location.reload()}
className="px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
Обновить страницу
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 via-white to-purple-50">
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Header */}
<div className="text-center mb-12">
<div className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-indigo-600 to-purple-600 rounded-3xl mb-6 shadow-xl">
<Building2 className="w-10 h-10 text-white" />
</div>
<h1 className="text-4xl font-black text-slate-800 mb-3">{survey.title}</h1>
{survey.description && (
<p className="text-lg text-slate-600 max-w-xl mx-auto">{survey.description}</p>
)}
<div className="mt-4 space-y-2">
{survey.address && (
<p className="text-sm text-slate-500 font-bold">📍 {survey.address}</p>
)}
{apartment && (
<p className="text-base text-indigo-600 font-black bg-indigo-50 px-4 py-2 rounded-xl inline-block">
Квартира {apartment}
</p>
)}
</div>
</div>
{/* Опрос */}
<form onSubmit={handleSubmit} className="bg-white rounded-3xl p-8 shadow-2xl border border-slate-200">
{/* Оценка от 0 до 10 */}
<div className="mb-8">
<label className="block text-lg font-black text-slate-800 mb-6 text-center">
Насколько вероятно, что вы порекомендуете нашу управляющую компанию друзьям или коллегам?
</label>
<div className="flex justify-center gap-2 flex-wrap">
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
<button
key={num}
type="button"
onClick={() => setScore(num)}
className={`w-14 h-14 rounded-xl font-black text-lg transition-all transform hover:scale-110 ${
score === num
? 'bg-gradient-to-br from-indigo-600 to-purple-600 text-white shadow-lg scale-110'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{num}
</button>
))}
</div>
<div className="flex justify-between mt-4 text-xs text-slate-500 font-bold">
<span>Точно нет</span>
<span>Нейтрально</span>
<span>Определенно да</span>
</div>
</div>
{/* Дополнительная информация */}
<div className="space-y-4 mb-8">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Комментарий (необязательно)
</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Поделитесь своими мыслями..."
rows={4}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none"
/>
</div>
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Ваше имя (необязательно)
</label>
<input
type="text"
value={respondentName}
onChange={(e) => setRespondentName(e.target.value)}
placeholder="Имя"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
{/* Номер квартиры - показываем, если уже указан, или позволяем ввести */}
{apartment ? (
<div className="p-4 bg-indigo-50 rounded-xl border border-indigo-200">
<label className="block text-sm font-bold text-indigo-700 mb-2">
Квартира
</label>
<p className="text-lg font-black text-indigo-800">{apartment}</p>
<button
type="button"
onClick={() => setApartment('')}
className="mt-2 text-xs text-indigo-600 hover:text-indigo-800 font-bold"
>
Изменить
</button>
</div>
) : (
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Квартира (необязательно)
</label>
<input
type="text"
value={apartment}
onChange={(e) => setApartment(e.target.value)}
placeholder="№ квартиры"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
)}
</div>
{/* Кнопка отправки */}
<button
type="submit"
disabled={score === null || isSubmitting}
className="w-full px-6 py-4 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 text-white rounded-xl text-base font-black uppercase tracking-wider shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all transform active:scale-95"
>
{isSubmitting ? 'Отправка...' : 'Отправить ответ'}
</button>
<p className="text-xs text-slate-400 text-center mt-4">
Ваши ответы анонимны и используются только для улучшения качества обслуживания
</p>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,403 @@
import React, { useState, useEffect } from 'react';
import { ArrowLeft, BarChart3, Users, TrendingUp, MessageSquare, Calendar, Building2, Eye, Key, Copy, CheckCircle2, X } from 'lucide-react';
import { apiClient } from '../../services/apiClient';
import { NPSSurvey, NPSSurveyStats, NPSResponse } from '../../types';
interface NPSSurveyStatsPageProps {
surveyId: string | number;
onBack?: () => void;
}
export const NPSSurveyStatsPage: React.FC<NPSSurveyStatsPageProps> = ({ surveyId, onBack }) => {
const [survey, setSurvey] = useState<NPSSurvey | null>(null);
const [stats, setStats] = useState<NPSSurveyStats | null>(null);
const [responses, setResponses] = useState<NPSResponse[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showAccessKeyModal, setShowAccessKeyModal] = useState(false);
const [copied, setCopied] = useState(false);
const [selectedMonth, setSelectedMonth] = useState<{ month: number; year: number }>(() => {
const now = new Date();
return { month: now.getMonth() + 1, year: now.getFullYear() };
});
useEffect(() => {
loadData();
}, [surveyId, selectedMonth]);
const loadData = async () => {
try {
setIsLoading(true);
const params = new URLSearchParams();
params.append('month', selectedMonth.month.toString());
params.append('year', selectedMonth.year.toString());
const [surveyData, statsData, responsesData] = await Promise.all([
apiClient.get<NPSSurvey>(`/pr/nps-surveys/${surveyId}`),
apiClient.get<NPSSurveyStats>(`/pr/nps-surveys/${surveyId}/stats?${params.toString()}`),
apiClient.get<NPSResponse[]>(`/pr/nps-surveys/${surveyId}/responses?${params.toString()}`)
]);
setSurvey(surveyData);
setStats(statsData);
setResponses(responsesData);
} catch (err) {
console.error('Error loading survey data:', err);
setSurvey(null);
setStats(null);
setResponses([]);
} finally {
setIsLoading(false);
}
};
const getSurveyLink = () => {
if (!survey) return '';
return `${window.location.origin}/nps/${survey.id}?key=${survey.accessKey}`;
};
const handleCopyLink = () => {
const link = getSurveyLink();
navigator.clipboard.writeText(link);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const getNPSColor = (nps: number) => {
if (nps >= 50) return 'text-emerald-600';
if (nps >= 0) return 'text-amber-600';
return 'text-red-600';
};
const getScoreColor = (score: number) => {
if (score >= 9) return 'bg-emerald-100 text-emerald-700 border-emerald-200';
if (score >= 7) return 'bg-amber-100 text-amber-700 border-amber-200';
return 'bg-red-100 text-red-700 border-red-200';
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-600">Загрузка статистики...</p>
</div>
</div>
);
}
if (!survey) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="text-center text-slate-600">
<p className="font-bold mb-2">Не удалось загрузить данные опроса NPS</p>
{onBack && (
<button
onClick={onBack}
className="mt-2 px-4 py-2 bg-primary-600 text-white rounded-xl text-sm font-bold"
>
Вернуться к списку опросов
</button>
)}
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
{/* Header */}
{onBack && (
<div className="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-slate-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<button
onClick={onBack}
className="flex items-center gap-2 text-slate-600 hover:text-slate-900 transition-colors group"
>
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
<span className="text-sm font-bold">Вернуться к списку опросов</span>
</button>
<div className="flex items-center gap-3">
<button
onClick={() => setShowAccessKeyModal(true)}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold flex items-center gap-2 transition-colors"
>
<Key className="w-4 h-4" />
Получить ссылку
</button>
</div>
</div>
</div>
</div>
)}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Заголовок */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<div className="p-4 bg-gradient-to-br from-indigo-600 to-purple-600 rounded-3xl text-white shadow-xl">
<BarChart3 className="w-8 h-8" />
</div>
<div>
<h1 className="text-3xl font-black text-slate-800">{survey.title}</h1>
{survey.address && (
<p className="text-sm text-slate-600 flex items-center gap-1 mt-1">
<Building2 className="w-4 h-4" />
{survey.address}
</p>
)}
</div>
</div>
{/* Выбор месяца */}
<div className="flex gap-2 items-center">
<select
value={selectedMonth.month}
onChange={(e) => setSelectedMonth({ ...selectedMonth, month: parseInt(e.target.value) })}
className="px-4 py-2 bg-white border border-slate-200 rounded-xl text-sm font-bold focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
{[1,2,3,4,5,6,7,8,9,10,11,12].map(m => (
<option key={m} value={m}>
{new Date(2000, m - 1).toLocaleDateString('ru-RU', { month: 'long' })}
</option>
))}
</select>
<select
value={selectedMonth.year}
onChange={(e) => setSelectedMonth({ ...selectedMonth, year: parseInt(e.target.value) })}
className="px-4 py-2 bg-white border border-slate-200 rounded-xl text-sm font-bold focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - 2 + i).map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</div>
</div>
{/* Основная статистика */}
{stats && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* NPS Score */}
<div className="lg:col-span-2 bg-white rounded-2xl p-6 shadow-lg border border-slate-200">
<h3 className="text-lg font-black text-slate-800 mb-6">Индекс NPS</h3>
<div className="text-center">
<div className={`inline-flex items-center justify-center w-40 h-40 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 text-white mb-6 shadow-xl ${getNPSColor(stats.nps)}`}>
<div className="text-center">
<div className="text-6xl font-black">{stats.nps > 0 ? '+' : ''}{stats.nps}</div>
<div className="text-sm font-bold opacity-90">NPS</div>
</div>
</div>
<p className="text-lg font-bold text-slate-800 mb-2">
{stats.nps >= 50 ? 'Отличный показатель'
: stats.nps >= 0 ? 'Хороший показатель'
: 'Требует внимания'}
</p>
<p className="text-sm text-slate-600">
Средняя оценка:{' '}
<span className="font-black">
{(stats.avgScore ?? 0).toFixed(1)}
</span>{' '}
из 10
</p>
</div>
</div>
{/* Общая статистика */}
<div className="bg-white rounded-2xl p-6 shadow-lg border border-slate-200">
<h3 className="text-lg font-black text-slate-800 mb-6">Общая статистика</h3>
<div className="space-y-4">
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Всего ответов</p>
<p className="text-3xl font-black text-slate-800">{stats.totalResponses ?? 0}</p>
</div>
<div className="pt-4 border-t border-slate-200">
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3">Распределение</p>
<div className="space-y-3">
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-bold text-emerald-700">Промоутеры</span>
<span className="text-sm font-black text-slate-800">
{stats.promoters ?? 0} ({(stats.promoterPercent ?? 0).toFixed(1)}%)
</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-3 overflow-hidden">
<div className="h-full bg-emerald-500 rounded-full transition-all duration-500" style={{ width: `${stats.promoterPercent}%` }}></div>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-bold text-amber-700">Нейтральные</span>
<span className="text-sm font-black text-slate-800">
{stats.passives ?? 0} (
{stats.totalResponses
? ((stats.passives / stats.totalResponses) * 100).toFixed(1)
: '0.0'
}%)
</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-3 overflow-hidden">
<div
className="h-full bg-amber-500 rounded-full transition-all duration-500"
style={{
width: `${stats.totalResponses ? (stats.passives / stats.totalResponses) * 100 : 0}%`
}}
></div>
</div>
</div>
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-bold text-red-700">Критики</span>
<span className="text-sm font-black text-slate-800">
{stats.detractors ?? 0} ({(stats.detractorPercent ?? 0).toFixed(1)}%)
</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-3 overflow-hidden">
<div className="h-full bg-red-500 rounded-full transition-all duration-500" style={{ width: `${stats.detractorPercent}%` }}></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Список ответов */}
<div className="bg-white rounded-2xl p-6 shadow-lg border border-slate-200">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-black text-slate-800">
Ответы жителей за {new Date(selectedMonth.year, selectedMonth.month - 1).toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' })}
</h3>
<span className="text-sm text-slate-500">Всего: {responses.length}</span>
</div>
{responses.length === 0 ? (
<div className="text-center py-12">
<MessageSquare className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<p className="text-slate-600">Пока нет ответов</p>
</div>
) : (
<div className="space-y-4">
{responses.map(response => (
<div
key={response.id}
className="p-4 bg-slate-50 rounded-xl border border-slate-200 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<div className={`px-3 py-1 rounded-lg border-2 font-black text-lg ${getScoreColor(response.score)}`}>
{response.score}
</div>
<div>
{response.respondentName && (
<p className="font-bold text-slate-800">{response.respondentName}</p>
)}
{response.apartment && (
<p className="text-xs text-slate-500">Квартира {response.apartment}</p>
)}
</div>
</div>
{response.comment && (
<p className="text-sm text-slate-700 mt-2 pl-12">{response.comment}</p>
)}
</div>
<div className="text-right">
<p className="text-xs text-slate-500">
{new Date(response.createdAt).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Модальное окно с ключом доступа */}
{showAccessKeyModal && survey && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-3xl p-8 max-w-lg w-full shadow-2xl">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-100 rounded-xl">
<Key className="w-6 h-6 text-indigo-600" />
</div>
<h3 className="text-xl font-black text-slate-800">Ссылка на опрос</h3>
</div>
<button
onClick={() => setShowAccessKeyModal(false)}
className="p-2 hover:bg-slate-100 rounded-xl transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Ключ доступа
</label>
<div className="flex gap-2">
<input
type="text"
value={survey.accessKey}
readOnly
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono"
/>
<button
onClick={() => {
navigator.clipboard.writeText(survey.accessKey);
alert('Ключ скопирован');
}}
className="px-4 py-3 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-sm font-bold transition-colors"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Ссылка на опрос
</label>
<div className="flex gap-2">
<input
type="text"
value={getSurveyLink()}
readOnly
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm"
/>
<button
onClick={handleCopyLink}
className="px-4 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
{copied ? <CheckCircle2 className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
<div className="mt-6 p-4 bg-amber-50 rounded-xl border border-amber-200">
<p className="text-xs text-amber-800">
<strong className="font-black">Важно:</strong> Ссылка содержит ключ доступа. Отправляйте её только жителям выбранного дома.
</p>
</div>
</div>
<button
onClick={() => setShowAccessKeyModal(false)}
className="w-full mt-6 px-4 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
Закрыть
</button>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,457 @@
import React, { useState, useEffect } from 'react';
import {
ClipboardList,
Eye,
Key,
Users,
Building2,
Copy,
CheckCircle2,
Play,
Pause,
X
} from 'lucide-react';
import { apiClient } from '../../services/apiClient';
import { NPSSurvey, NPSSurveyStats, Building } from '../../types';
export const NPSSurveysManager: React.FC = () => {
const [surveys, setSurveys] = useState<NPSSurvey[]>([]);
const [buildings, setBuildings] = useState<Building[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showAccessKeyModal, setShowAccessKeyModal] = useState(false);
const [selectedSurvey, setSelectedSurvey] = useState<NPSSurvey | null>(null);
const [stats, setStats] = useState<Record<number, NPSSurveyStats>>({});
const [filterBuilding, setFilterBuilding] = useState<string>('');
const [filterStatus, setFilterStatus] = useState<string>('');
const [selectedMonth, setSelectedMonth] = useState<{ month: number; year: number }>(() => {
const now = new Date();
return { month: now.getMonth() + 1, year: now.getFullYear() };
});
useEffect(() => {
loadSurveys();
loadBuildings();
}, [filterBuilding, filterStatus]);
useEffect(() => {
// Перезагружаем статистику при изменении месяца
surveys.forEach(survey => {
loadStats(survey.id, selectedMonth.month, selectedMonth.year);
});
}, [selectedMonth]);
const loadSurveys = async () => {
try {
setIsLoading(true);
const params: any = {};
if (filterBuilding) params.building_id = filterBuilding;
if (filterStatus) params.status = filterStatus;
const queryString = new URLSearchParams(params).toString();
const data = await apiClient.get(`/pr/nps-surveys${queryString ? `?${queryString}` : ''}`);
setSurveys(data);
// Загружаем статистику для всех опросов с учетом выбранного месяца
data.forEach((survey: NPSSurvey) => {
loadStats(survey.id, selectedMonth.month, selectedMonth.year);
});
} catch (err) {
console.error('Error loading surveys:', err);
} finally {
setIsLoading(false);
}
};
const loadBuildings = async () => {
try {
const data = await apiClient.get('/buildings');
setBuildings(data);
} catch (err) {
console.error('Error loading buildings:', err);
}
};
const loadStats = async (surveyId: number, month?: number, year?: number) => {
try {
const params = new URLSearchParams();
if (month) params.append('month', month.toString());
if (year) params.append('year', year.toString());
const queryString = params.toString();
const data = await apiClient.get(`/pr/nps-surveys/${surveyId}/stats${queryString ? `?${queryString}` : ''}`);
setStats(prev => ({ ...prev, [surveyId]: data }));
} catch (err) {
console.error('Error loading stats:', err);
}
};
const handleUpdateStatus = async (survey: NPSSurvey, newStatus: 'draft' | 'active' | 'closed') => {
try {
const updated = await apiClient.put(`/pr/nps-surveys/${survey.id}`, { status: newStatus });
setSurveys(surveys.map(s => s.id === survey.id ? updated : s));
} catch (err: any) {
alert(`Ошибка обновления статуса: ${err.message || 'Неизвестная ошибка'}`);
}
};
const getSurveyLink = (survey: NPSSurvey, apartment?: string) => {
const baseUrl = `${window.location.origin}/nps/${survey.id}?key=${survey.accessKey}`;
if (apartment) {
return `${baseUrl}&apartment=${encodeURIComponent(apartment)}`;
}
return baseUrl;
};
const [copiedSurveyId, setCopiedSurveyId] = useState<number | null>(null);
const [showApartmentInput, setShowApartmentInput] = useState<number | null>(null);
const [apartmentForLink, setApartmentForLink] = useState<string>('');
const handleCopyLink = (survey: NPSSurvey, apartment?: string) => {
const link = getSurveyLink(survey, apartment);
navigator.clipboard.writeText(link);
setCopiedSurveyId(survey.id);
setTimeout(() => setCopiedSurveyId(null), 2000);
};
const getNPSColor = (nps: number) => {
if (nps >= 50) return 'text-emerald-600';
if (nps >= 0) return 'text-amber-600';
return 'text-red-600';
};
const getStatusColor = (status: string) => {
switch (status) {
case 'active': return 'bg-emerald-100 text-emerald-700';
case 'closed': return 'bg-slate-100 text-slate-700';
default: return 'bg-amber-100 text-amber-700';
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'active': return 'Активен';
case 'closed': return 'Закрыт';
default: return 'Черновик';
}
};
return (
<div className="space-y-6">
{/* Заголовок и фильтры */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h3 className="text-lg font-black text-slate-800">Опросы NPS</h3>
<p className="text-xs text-slate-500 mt-1">Опросы создаются автоматически для каждого дома</p>
</div>
<div className="flex gap-2 flex-wrap">
{/* Выбор месяца для статистики */}
<div className="flex gap-2 items-center">
<select
value={selectedMonth.month}
onChange={(e) => setSelectedMonth({ ...selectedMonth, month: parseInt(e.target.value) })}
className="px-3 py-2 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
{[1,2,3,4,5,6,7,8,9,10,11,12].map(m => (
<option key={m} value={m}>
{new Date(2000, m - 1).toLocaleDateString('ru-RU', { month: 'long' })}
</option>
))}
</select>
<select
value={selectedMonth.year}
onChange={(e) => setSelectedMonth({ ...selectedMonth, year: parseInt(e.target.value) })}
className="px-3 py-2 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
{Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - 2 + i).map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
<select
value={filterBuilding}
onChange={(e) => setFilterBuilding(e.target.value)}
className="px-4 py-2 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">Все дома</option>
{buildings.map(b => (
<option key={b.id} value={b.id}>{b.passport?.address || b.id}</option>
))}
</select>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-4 py-2 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">Все статусы</option>
<option value="draft">Черновик</option>
<option value="active">Активен</option>
<option value="closed">Закрыт</option>
</select>
</div>
</div>
{/* Список опросов */}
{isLoading ? (
<div className="text-center py-12">
<div className="w-12 h-12 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-slate-600">Загрузка опросов...</p>
</div>
) : surveys.length === 0 ? (
<div className="text-center py-12 bg-slate-50 rounded-2xl border-2 border-dashed border-slate-200">
<ClipboardList className="w-12 h-12 text-slate-400 mx-auto mb-4" />
<p className="text-slate-600 font-bold">Нет опросов</p>
<p className="text-sm text-slate-500 mt-1">Опросы создаются автоматически для каждого дома 1 числа каждого месяца</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{surveys.map(survey => {
const surveyStats = stats[survey.id];
return (
<div
key={survey.id}
className="bg-white rounded-2xl p-6 shadow-lg border border-slate-200 hover:shadow-xl transition-shadow"
>
{/* Заголовок */}
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h4 className="text-lg font-black text-slate-800 mb-1">{survey.title}</h4>
{survey.address && (
<p className="text-sm text-slate-600 flex items-center gap-1">
<Building2 className="w-4 h-4" />
{survey.address}
</p>
)}
</div>
<span className={`px-3 py-1 rounded-lg text-xs font-bold ${getStatusColor(survey.status)}`}>
{getStatusLabel(survey.status)}
</span>
</div>
{/* Статистика */}
{surveyStats && surveyStats.totalResponses > 0 ? (
<div className="mb-4 p-4 bg-gradient-to-br from-indigo-50 to-purple-50 rounded-xl border border-indigo-100">
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-xs font-bold text-slate-600 uppercase tracking-wider mb-1">NPS</p>
<p className={`text-3xl font-black ${getNPSColor(surveyStats.nps)}`}>
{surveyStats.nps > 0 ? '+' : ''}{surveyStats.nps}
</p>
</div>
<div className="text-right">
<p className="text-xs text-slate-600 mb-1">Средняя оценка</p>
<p className="text-2xl font-black text-slate-800">{surveyStats.avgScore.toFixed(1)}</p>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-center pt-3 border-t border-indigo-200">
<div>
<p className="text-xs text-slate-600 mb-1">Промоутеры</p>
<p className="text-sm font-black text-emerald-600">{surveyStats.promoters}</p>
</div>
<div>
<p className="text-xs text-slate-600 mb-1">Нейтральные</p>
<p className="text-sm font-black text-amber-600">{surveyStats.passives}</p>
</div>
<div>
<p className="text-xs text-slate-600 mb-1">Критики</p>
<p className="text-sm font-black text-red-600">{surveyStats.detractors}</p>
</div>
</div>
<p className="text-xs text-slate-500 mt-3 text-center">
Ответов за {new Date(selectedMonth.year, selectedMonth.month - 1).toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' })}: <span className="font-black">{surveyStats.totalResponses}</span>
</p>
</div>
) : (
<div className="mb-4 p-4 bg-slate-50 rounded-xl border border-slate-200 text-center">
<p className="text-sm text-slate-500">
Нет ответов за {new Date(selectedMonth.year, selectedMonth.month - 1).toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' })}
</p>
</div>
)}
{/* Действия */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => {
setSelectedSurvey(survey);
setShowAccessKeyModal(true);
}}
className="px-3 py-2 bg-emerald-100 hover:bg-emerald-200 text-emerald-700 rounded-xl text-xs font-bold flex items-center gap-2 transition-colors"
title="Получить ссылку для жителей"
>
<Key className="w-4 h-4" />
Ссылка
</button>
<button
onClick={() => {
setShowApartmentInput(survey.id);
setApartmentForLink('');
}}
className="px-3 py-2 bg-blue-100 hover:bg-blue-200 text-blue-700 rounded-xl text-xs font-bold flex items-center gap-2 transition-colors"
title="Ссылка для чата с квартирой"
>
<Users className="w-4 h-4" />
Для чата
</button>
{survey.status === 'draft' && (
<button
onClick={() => handleUpdateStatus(survey, 'active')}
className="px-3 py-2 bg-emerald-100 hover:bg-emerald-200 text-emerald-700 rounded-xl text-xs font-bold flex items-center gap-2 transition-colors"
title="Активировать"
>
<Play className="w-4 h-4" />
</button>
)}
{survey.status === 'active' && (
<button
onClick={() => handleUpdateStatus(survey, 'closed')}
className="px-3 py-2 bg-amber-100 hover:bg-amber-200 text-amber-700 rounded-xl text-xs font-bold flex items-center gap-2 transition-colors"
title="Закрыть"
>
<Pause className="w-4 h-4" />
</button>
)}
{/* Модальное окно для ввода номера квартиры */}
{showApartmentInput === survey.id && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-3xl p-6 max-w-md w-full shadow-2xl">
<h3 className="text-lg font-black text-slate-800 mb-4">Ссылка для отправки в чат</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Номер квартиры
</label>
<input
type="text"
value={apartmentForLink}
onChange={(e) => setApartmentForLink(e.target.value)}
placeholder="Например: 45"
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
autoFocus
/>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setShowApartmentInput(null);
setApartmentForLink('');
}}
className="flex-1 px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-sm font-bold transition-colors"
>
Отмена
</button>
<button
onClick={() => {
if (apartmentForLink.trim()) {
handleCopyLink(survey, apartmentForLink.trim());
setShowApartmentInput(null);
setApartmentForLink('');
}
}}
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
Копировать ссылку
</button>
</div>
{apartmentForLink.trim() && (
<div className="mt-4 p-3 bg-indigo-50 rounded-xl border border-indigo-200">
<p className="text-xs text-indigo-700 mb-2 font-bold">Ссылка будет содержать:</p>
<p className="text-xs text-indigo-800 font-mono break-all">
{getSurveyLink(survey, apartmentForLink.trim())}
</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
})}
</div>
)}
{/* Модальное окно с ключом доступа */}
{showAccessKeyModal && selectedSurvey && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-3xl p-8 max-w-md w-full shadow-2xl">
<h3 className="text-xl font-black text-slate-800 mb-6">Ссылка на опрос</h3>
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-600 uppercase tracking-wider mb-2">
Ключ доступа
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={selectedSurvey.accessKey}
readOnly
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono"
/>
<button
onClick={() => {
navigator.clipboard.writeText(selectedSurvey.accessKey);
alert('Ключ скопирован');
}}
className="px-4 py-3 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-sm font-bold transition-colors"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 uppercase tracking-wider mb-2">
Ссылка на опрос
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={getSurveyLink(selectedSurvey)}
readOnly
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm"
/>
<button
onClick={() => handleCopyLink(selectedSurvey)}
className="px-4 py-3 bg-primary-600 hover:bg-primary-700 text-white rounded-xl text-sm font-bold transition-colors"
>
{copiedSurveyId === selectedSurvey.id ? (
<CheckCircle2 className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
<div className="mt-4 p-4 bg-emerald-50 rounded-xl border border-emerald-200">
<p className="text-xs font-bold text-emerald-800 mb-2">💬 Для отправки в чат:</p>
<p className="text-xs text-emerald-700 mb-3">
Добавьте параметр <code className="bg-emerald-100 px-1 rounded">?apartment=XX</code> к ссылке,
чтобы автоматически указать номер квартиры жителя.
</p>
<p className="text-xs text-emerald-600">
Пример: <code className="bg-emerald-100 px-1 rounded">
{getSurveyLink(selectedSurvey)}&apartment=45
</code>
</p>
</div>
<div className="mt-6 p-4 bg-amber-50 rounded-xl border border-amber-200">
<p className="text-xs text-amber-800">
<strong className="font-black">Важно:</strong> Ссылка содержит ключ доступа. Отправляйте её только жителям выбранного дома.
</p>
</div>
</div>
<button
onClick={() => {
setShowAccessKeyModal(false);
setSelectedSurvey(null);
}}
className="w-full mt-6 px-4 py-3 bg-primary-600 hover:bg-primary-700 text-white rounded-xl text-sm font-bold transition-colors"
>
Закрыть
</button>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,497 @@
import React, { useState, useEffect } from 'react';
import { Incident, Building } from '../../types';
import { apiClient } from '../../services/apiClient';
import { backendApi } from '../../services/apiClient';
import {
ShieldAlert,
AlertCircle,
MessageCircle,
CheckCircle2,
Phone,
Search,
Filter,
Trash2,
ChevronRight,
Plus,
Loader2,
X
} from 'lucide-react';
export const NegativeResolution: React.FC = () => {
const [incidents, setIncidents] = useState<Incident[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [filterStatus, setFilterStatus] = useState<string>('');
const [filterType, setFilterType] = useState<string>('');
const [search, setSearch] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
useEffect(() => {
loadIncidents();
}, [filterStatus, filterType]);
const loadIncidents = async () => {
try {
setIsLoading(true);
const params = new URLSearchParams();
if (filterStatus) params.append('status', filterStatus);
if (filterType) params.append('type', filterType);
const queryString = params.toString();
const path = `/pr/incidents${queryString ? `?${queryString}` : ''}`;
const data = await apiClient.get<Incident[]>(path);
setIncidents(data);
} catch (err) {
console.error('Error loading incidents:', err);
setIncidents([]);
} finally {
setIsLoading(false);
}
};
const handleResolve = async (id: number, resolutionNotes?: string) => {
try {
await apiClient.put(`/pr/incidents/${id}/resolve`, { resolution_notes: resolutionNotes });
await loadIncidents();
} catch (err) {
console.error('Error resolving incident:', err);
alert('Ошибка разрешения инцидента');
}
};
const handleStatusChange = async (id: number, status: Incident['status']) => {
try {
await apiClient.put(`/pr/incidents/${id}`, { status });
await loadIncidents();
} catch (err) {
console.error('Error updating incident status:', err);
alert('Ошибка обновления статуса');
}
};
const filteredIncidents = incidents.filter(incident => {
const matchesSearch = !search ||
incident.title.toLowerCase().includes(search.toLowerCase()) ||
incident.description.toLowerCase().includes(search.toLowerCase()) ||
(incident.address && incident.address.toLowerCase().includes(search.toLowerCase()));
return matchesSearch;
});
const newIncidents = filteredIncidents.filter(i => i.status === 'new');
const inProgressIncidents = filteredIncidents.filter(i => i.status === 'in_progress');
return (
<div className="space-y-6 animate-fade-in">
{/* Header Alert */}
<div className="bg-red-600 rounded-[2rem] p-6 text-white shadow-xl shadow-red-500/20 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-white/20 rounded-2xl animate-pulse">
<ShieldAlert className="w-8 h-8 text-white"/>
</div>
<div>
<h3 className="text-xl font-black leading-none">Работа с инцидентами</h3>
<p className="text-red-100 text-xs mt-1 font-medium opacity-80">
{newIncidents.length} новых инцидентов {inProgressIncidents.length} в работе
</p>
</div>
</div>
<button
onClick={() => setShowCreateForm(true)}
className="bg-white text-red-600 px-6 py-2.5 rounded-xl text-xs font-black uppercase tracking-widest shadow-lg active:scale-95 transition-all"
>
<Plus className="w-4 h-4 inline mr-2" />
Создать инцидент
</button>
</div>
{/* Filters */}
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Поиск по инцидентам..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500 shadow-sm"
/>
</div>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="px-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">Все статусы</option>
<option value="new">Новые</option>
<option value="in_progress">В работе</option>
<option value="resolved">Разрешены</option>
<option value="closed">Закрыты</option>
</select>
<select
value={filterType}
onChange={e => setFilterType(e.target.value)}
className="px-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">Все типы</option>
<option value="property_damage">Повреждение имущества</option>
<option value="debtor_complaint">Жалоба должника</option>
<option value="service_quality">Качество услуг</option>
<option value="other">Другое</option>
</select>
</div>
{/* Create Form Modal */}
{showCreateForm && (
<IncidentCreateForm
onClose={() => setShowCreateForm(false)}
onSuccess={() => {
setShowCreateForm(false);
loadIncidents();
}}
/>
)}
{/* Incidents List */}
{isLoading ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>
) : filteredIncidents.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<p>Инциденты не найдены</p>
</div>
) : (
<div className="space-y-4">
{filteredIncidents.map(incident => (
<IncidentCard
key={incident.id}
incident={incident}
onResolve={handleResolve}
onStatusChange={handleStatusChange}
/>
))}
</div>
)}
</div>
);
};
interface IncidentCardProps {
incident: Incident;
onResolve: (id: number, notes?: string) => void;
onStatusChange: (id: number, status: Incident['status']) => void;
}
const IncidentCard: React.FC<IncidentCardProps> = ({ incident, onResolve, onStatusChange }) => {
const [showResolveForm, setShowResolveForm] = useState(false);
const [resolutionNotes, setResolutionNotes] = useState('');
const typeLabels = {
property_damage: 'Повреждение имущества',
debtor_complaint: 'Жалоба должника',
service_quality: 'Качество услуг',
other: 'Другое'
};
const statusColors = {
new: 'bg-red-50 text-red-600 border-red-100',
in_progress: 'bg-amber-50 text-amber-600 border-amber-100',
resolved: 'bg-emerald-50 text-emerald-600 border-emerald-100',
closed: 'bg-slate-50 text-slate-600 border-slate-100'
};
const priorityColors = {
low: 'bg-slate-100 text-slate-600',
medium: 'bg-blue-100 text-blue-600',
high: 'bg-orange-100 text-orange-600',
urgent: 'bg-red-100 text-red-600'
};
return (
<div className="bg-white p-6 rounded-[2.5rem] border-2 border-red-50 shadow-sm relative overflow-hidden group hover:border-red-200 transition-all">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-red-50 flex items-center justify-center text-red-500">
<AlertCircle className="w-7 h-7"/>
</div>
<div>
<h4 className="font-black text-slate-800 text-base leading-tight">{incident.title}</h4>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className={`text-[10px] font-black px-2 py-0.5 rounded-full uppercase tracking-tighter border ${statusColors[incident.status]}`}>
{incident.status === 'new' ? 'Новый' :
incident.status === 'in_progress' ? 'В работе' :
incident.status === 'resolved' ? 'Разрешен' : 'Закрыт'}
</span>
<span className={`text-[10px] font-black px-2 py-0.5 rounded-full uppercase tracking-tighter ${priorityColors[incident.priority]}`}>
{incident.priority === 'urgent' ? 'Срочно' :
incident.priority === 'high' ? 'Высокий' :
incident.priority === 'medium' ? 'Средний' : 'Низкий'}
</span>
<span className="text-[10px] text-slate-400 font-bold uppercase">
{typeLabels[incident.type]}
</span>
{incident.address && (
<span className="text-[10px] text-slate-400 font-bold">
{incident.address}
</span>
)}
</div>
</div>
</div>
</div>
<div className="bg-slate-50 p-4 rounded-2xl border border-slate-100 mb-6 text-sm text-slate-600 leading-relaxed">
{incident.description}
</div>
{incident.review && (
<div className="bg-amber-50 p-3 rounded-xl border border-amber-100 mb-4 text-xs text-amber-800 italic">
<strong>Связанный отзыв:</strong> «{incident.review.text}» (Рейтинг: {incident.review.rating}/10)
</div>
)}
{/* Actions */}
{!showResolveForm ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
{incident.status === 'new' && (
<button
onClick={() => onStatusChange(incident.id, 'in_progress')}
className="py-3 bg-amber-600 text-white rounded-2xl text-[10px] font-black uppercase flex items-center justify-center gap-2 shadow-lg active:scale-95 transition-all"
>
<AlertCircle className="w-4 h-4"/> Взять в работу
</button>
)}
{incident.status === 'in_progress' && (
<button
onClick={() => setShowResolveForm(true)}
className="py-3 bg-emerald-600 text-white rounded-2xl text-[10px] font-black uppercase flex items-center justify-center gap-2 shadow-lg shadow-emerald-500/20 active:scale-95 transition-all"
>
<CheckCircle2 className="w-4 h-4"/> Разрешить инцидент
</button>
)}
{incident.status === 'resolved' && (
<button
onClick={() => onStatusChange(incident.id, 'closed')}
className="py-3 bg-slate-600 text-white rounded-2xl text-[10px] font-black uppercase flex items-center justify-center gap-2 shadow-lg active:scale-95 transition-all"
>
<CheckCircle2 className="w-4 h-4"/> Закрыть
</button>
)}
</div>
) : (
<div className="space-y-3">
<textarea
value={resolutionNotes}
onChange={e => setResolutionNotes(e.target.value)}
placeholder="Опишите, как был разрешен инцидент..."
className="w-full p-3 border border-slate-200 rounded-xl text-sm resize-none"
rows={3}
/>
<div className="flex gap-2">
<button
onClick={() => {
onResolve(incident.id, resolutionNotes);
setShowResolveForm(false);
setResolutionNotes('');
}}
className="px-4 py-2 bg-emerald-600 text-white rounded-xl text-xs font-black uppercase flex items-center gap-2"
>
<CheckCircle2 className="w-4 h-4" />
Сохранить
</button>
<button
onClick={() => {
setShowResolveForm(false);
setResolutionNotes('');
}}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-xl text-xs font-black uppercase"
>
Отмена
</button>
</div>
</div>
)}
{incident.assignedTo && (
<div className="mt-4 pt-4 border-t border-slate-50 flex items-center gap-2 text-[9px] font-black text-slate-400 uppercase tracking-widest">
<CheckCircle2 className="w-3 h-3 text-slate-300"/> Ответственный: {incident.assignedTo}
</div>
)}
</div>
);
};
interface IncidentCreateFormProps {
onClose: () => void;
onSuccess: () => void;
}
const IncidentCreateForm: React.FC<IncidentCreateFormProps> = ({ onClose, onSuccess }) => {
const [formData, setFormData] = useState({
building_id: '',
type: 'service_quality' as Incident['type'],
title: '',
description: '',
priority: 'medium' as Incident['priority'],
assigned_to: ''
});
const [buildings, setBuildings] = useState<Building[]>([]);
const [isLoadingBuildings, setIsLoadingBuildings] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
loadBuildings();
}, []);
const loadBuildings = async () => {
try {
setIsLoadingBuildings(true);
const data = await backendApi.getBuildings();
setBuildings(data);
} catch (err) {
console.error('Error loading buildings:', err);
setBuildings([]);
} finally {
setIsLoadingBuildings(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
setIsSubmitting(true);
await apiClient.post('/pr/incidents', {
...formData,
created_by: 'Current User' // TODO: получить из контекста
});
onSuccess();
} catch (err) {
console.error('Error creating incident:', err);
alert('Ошибка создания инцидента');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-3xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-slate-800">Создать инцидент</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-xl">
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Дом
</label>
{isLoadingBuildings ? (
<div className="w-full p-3 border border-slate-200 rounded-xl text-sm text-slate-400">
Загрузка домов...
</div>
) : (
<select
value={formData.building_id}
onChange={e => setFormData({ ...formData, building_id: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
>
<option value="">Выберите дом</option>
{buildings.map(building => (
<option key={building.id} value={building.id}>
{building.passport?.address || building.id}
</option>
))}
</select>
)}
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Тип инцидента
</label>
<select
value={formData.type}
onChange={e => setFormData({ ...formData, type: e.target.value as Incident['type'] })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
>
<option value="property_damage">Повреждение имущества</option>
<option value="debtor_complaint">Жалоба должника</option>
<option value="service_quality">Качество услуг</option>
<option value="other">Другое</option>
</select>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Заголовок
</label>
<input
type="text"
value={formData.title}
onChange={e => setFormData({ ...formData, title: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
/>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Описание
</label>
<textarea
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm resize-none"
rows={4}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Приоритет
</label>
<select
value={formData.priority}
onChange={e => setFormData({ ...formData, priority: e.target.value as Incident['priority'] })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
>
<option value="low">Низкий</option>
<option value="medium">Средний</option>
<option value="high">Высокий</option>
<option value="urgent">Срочно</option>
</select>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Ответственный (опционально)
</label>
<input
type="text"
value={formData.assigned_to}
onChange={e => setFormData({ ...formData, assigned_to: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
/>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-6 py-3 bg-primary-600 text-white rounded-xl text-xs font-black uppercase tracking-widest disabled:opacity-50"
>
{isSubmitting ? 'Создание...' : 'Создать инцидент'}
</button>
<button
type="button"
onClick={onClose}
className="px-6 py-3 bg-slate-100 text-slate-600 rounded-xl text-xs font-black uppercase tracking-widest"
>
Отмена
</button>
</div>
</form>
</div>
</div>
);
};

812
components/pr/PRFeedbackFeed.tsx Executable file
View File

@@ -0,0 +1,812 @@
import React, { useState, useMemo, useEffect } from 'react';
import { ExternalLink } from 'lucide-react';
import { Review, AIAnalysisResult, AnalyzedFeedback } from '../../types';
import { analyzeResidentFeedback } from '../../services/geminiService';
import { apiClient } from '../../services/apiClient';
import {
Smile,
TrendingUp,
TrendingDown,
Sparkles,
Bot,
Filter,
ThumbsUp,
ThumbsDown,
MessageSquare,
Loader2,
Search,
Archive,
AlertCircle,
CheckCircle2,
Plus,
X
} from 'lucide-react';
import { Building } from '../../types';
import { backendApi } from '../../services/apiClient';
export const PRFeedbackFeed: React.FC = () => {
const [reviews, setReviews] = useState<Review[]>([]);
const [aiResult, setAiResult] = useState<AIAnalysisResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingReviews, setIsLoadingReviews] = useState(true);
const [reviewsServiceUnavailable, setReviewsServiceUnavailable] = useState(false);
const [search, setSearch] = useState('');
const [filterSource, setFilterSource] = useState<string>('');
const [filterStatus, setFilterStatus] = useState<string>('');
const [filterBuilding, setFilterBuilding] = useState<string>('');
const [filterRating, setFilterRating] = useState<string>('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [fetchSource, setFetchSource] = useState<'yandex_maps' | '2gis' | ''>('');
const [isFetching, setIsFetching] = useState(false);
const [stats, setStats] = useState<any>(null);
const [buildings, setBuildings] = useState<Building[]>([]);
const [isLoadingStats, setIsLoadingStats] = useState(false);
// Загрузка отзывов
useEffect(() => {
loadReviews();
loadStats();
}, [filterSource, filterStatus, filterBuilding]);
useEffect(() => {
loadBuildings();
}, []);
const loadBuildings = async () => {
try {
const data = await backendApi.getBuildings();
setBuildings(data);
} catch (err) {
console.error('Error loading buildings:', err);
setBuildings([]);
}
};
const loadStats = async () => {
try {
setIsLoadingStats(true);
const params = new URLSearchParams();
if (filterBuilding) params.append('building_id', filterBuilding);
const queryString = params.toString();
const path = `/pr/reviews/stats${queryString ? `?${queryString}` : ''}`;
const data = await apiClient.get<any>(path);
setStats(data);
} catch (err) {
console.error('Error loading stats:', err);
setStats(null);
} finally {
setIsLoadingStats(false);
}
};
const loadReviews = async () => {
try {
setIsLoadingReviews(true);
setReviewsServiceUnavailable(false);
const params = new URLSearchParams();
if (filterSource) params.append('source', filterSource);
if (filterStatus) params.append('status', filterStatus);
if (filterBuilding) params.append('building_id', filterBuilding);
const queryString = params.toString();
const path = `/pr/reviews${queryString ? `?${queryString}` : ''}`;
const data = await apiClient.get<Review[]>(path);
setReviews(Array.isArray(data) ? data : []);
} catch (err: any) {
setReviews([]);
setReviewsServiceUnavailable(true);
if (err?.status !== 500 && !err?.message?.includes('fetch')) {
console.warn('Reviews service:', err?.message || err);
}
} finally {
setIsLoadingReviews(false);
}
};
const handleAnalyze = async () => {
setIsLoading(true);
setAiResult(null);
// Анализируем только отфильтрованные отзывы
const filteredReviews = reviews.filter(r => {
// Фильтр по рейтингу
if (filterRating === 'negative' && r.rating > 3) return false;
if (filterRating === 'neutral' && (r.rating <= 3 || r.rating >= 8)) return false;
if (filterRating === 'positive' && r.rating < 8) return false;
return true;
});
// Преобразуем Review в ResidentFeedback для анализа
const feedback = filteredReviews.map(r => ({
id: String(r.id),
buildingId: r.buildingId,
address: r.address || '',
date: r.date,
text: r.text,
source: r.source,
rating: r.rating
}));
const result = await analyzeResidentFeedback(feedback);
setAiResult(result);
setIsLoading(false);
};
const handleStatusChange = async (reviewId: number, status: 'processed' | 'archived') => {
try {
await apiClient.put(`/pr/reviews/${reviewId}/status`, { status });
await loadReviews();
} catch (err) {
console.error('Error updating review status:', err);
alert('Ошибка обновления статуса отзыва');
}
};
const handleCreateIncident = async (reviewId: number) => {
try {
await apiClient.post(`/pr/incidents/from-review/${reviewId}`, {
created_by: 'Current User' // TODO: получить из контекста пользователя
});
await loadReviews();
alert('Инцидент создан успешно');
} catch (err) {
console.error('Error creating incident:', err);
alert('Ошибка создания инцидента');
}
};
const handleFetchReviews = async () => {
if (!fetchSource) {
alert('Выберите источник для загрузки');
return;
}
try {
setIsFetching(true);
const result = await apiClient.post<{ success: boolean; parsed: number; found: number; message?: string; details?: string }>(
'/pr/reviews/fetch',
{ source: fetchSource }
);
await loadReviews();
const msg = result.message || `Загружено отзывов: ${result.parsed ?? 0}`;
alert(msg);
} catch (err: any) {
const status = err?.status ?? err?.response?.status;
const details = err?.details ?? err?.response?.data?.details ?? err?.message;
if (status === 400) {
alert('Укажите API ключ в Настройках → Интеграции');
} else {
alert(details || 'Ошибка загрузки отзывов');
}
} finally {
setIsFetching(false);
}
};
const analyzedFeedback = useMemo((): (AnalyzedFeedback & Review)[] => {
// Применяем фильтры
let filtered = reviews;
// Фильтр по рейтингу
if (filterRating === 'negative') {
filtered = filtered.filter(r => r.rating <= 3);
} else if (filterRating === 'neutral') {
filtered = filtered.filter(r => r.rating > 3 && r.rating < 8);
} else if (filterRating === 'positive') {
filtered = filtered.filter(r => r.rating >= 8);
}
// Поиск
if (search) {
filtered = filtered.filter(r =>
(r.address || '').toLowerCase().includes(search.toLowerCase()) ||
r.text.toLowerCase().includes(search.toLowerCase()) ||
(r.authorName || '').toLowerCase().includes(search.toLowerCase())
);
}
const raw = aiResult ? aiResult.analyzedFeedback.map(af => {
// Находим соответствующий review для сохранения всех полей
const review = filtered.find(r => String(r.id) === af.id);
return {
...af,
...review,
id: af.id
};
}) : filtered.map(r => ({
id: String(r.id),
buildingId: r.buildingId,
address: r.address || '',
date: r.date,
text: r.text,
source: r.source,
rating: r.rating,
category: 'Неизвестно',
sentiment: r.rating >= 8 ? 'Positive' as const : r.rating <= 3 ? 'Negative' as const : 'Neutral' as const,
// Сохраняем все поля Review
authorName: r.authorName,
sourceUrl: r.sourceUrl,
status: r.status,
processedAt: r.processedAt,
processedBy: r.processedBy,
createdAt: r.createdAt,
updatedAt: r.updatedAt
}));
return raw;
}, [reviews, aiResult, search, filterRating]);
return (
<div className="space-y-6 animate-fade-in">
{/* Statistics Card */}
{stats && !isLoadingStats && (
<div className="bg-white rounded-[2rem] p-6 border border-slate-200 shadow-sm">
<h4 className="font-bold text-slate-800 mb-4">Статистика отзывов</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-slate-50 rounded-xl">
<p className="text-2xl font-black text-slate-800">{stats.total || 0}</p>
<p className="text-xs text-slate-500 font-bold uppercase tracking-wider mt-1">Всего</p>
</div>
<div className="text-center p-4 bg-blue-50 rounded-xl">
<p className="text-2xl font-black text-blue-600">{stats.new_count || 0}</p>
<p className="text-xs text-blue-600 font-bold uppercase tracking-wider mt-1">Новых</p>
</div>
<div className="text-center p-4 bg-emerald-50 rounded-xl">
<p className="text-2xl font-black text-emerald-600">{stats.processed_count || 0}</p>
<p className="text-xs text-emerald-600 font-bold uppercase tracking-wider mt-1">Обработано</p>
</div>
<div className="text-center p-4 bg-amber-50 rounded-xl">
<p className="text-2xl font-black text-amber-600">{stats.avg_rating ? parseFloat(stats.avg_rating).toFixed(1) : '0.0'}</p>
<p className="text-xs text-amber-600 font-bold uppercase tracking-wider mt-1">Средний рейтинг</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div className="text-center p-3 bg-emerald-50 rounded-lg">
<p className="text-lg font-black text-emerald-600">{stats.positive_count || 0}</p>
<p className="text-[10px] text-emerald-600 font-bold uppercase">Положительных</p>
</div>
<div className="text-center p-3 bg-red-50 rounded-lg">
<p className="text-lg font-black text-red-600">{stats.negative_count || 0}</p>
<p className="text-[10px] text-red-600 font-bold uppercase">Негативных</p>
</div>
<div className="text-center p-3 bg-blue-50 rounded-lg">
<p className="text-lg font-black text-blue-600">{stats.yandex_count || 0}</p>
<p className="text-[10px] text-blue-600 font-bold uppercase">Яндекс Карты</p>
</div>
<div className="text-center p-3 bg-purple-50 rounded-lg">
<p className="text-lg font-black text-purple-600">{stats.gis2_count || 0}</p>
<p className="text-[10px] text-purple-600 font-bold uppercase">2ГИС</p>
</div>
</div>
</div>
)}
{/* Поиск и фильтры — сверху */}
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Поиск по отзывам и адресам..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500 shadow-sm"
/>
</div>
<div className="flex gap-2 flex-wrap">
<select
value={filterSource}
onChange={e => setFilterSource(e.target.value)}
className="px-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">Все источники</option>
<option value="yandex_maps">Яндекс Карты</option>
<option value="2gis">2ГИС</option>
<option value="internal">Внутренний</option>
<option value="other">Другое</option>
</select>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="px-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">Все статусы</option>
<option value="new">Новые</option>
<option value="processed">Обработанные</option>
<option value="archived">Архив</option>
</select>
<select
value={filterBuilding}
onChange={e => setFilterBuilding(e.target.value)}
className="px-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">Все здания</option>
{buildings.map(building => (
<option key={building.id} value={building.id}>
{building.passport?.address || building.id}
</option>
))}
</select>
<select
value={filterRating}
onChange={e => setFilterRating(e.target.value)}
className="px-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="">Все рейтинги</option>
<option value="negative">Негативные (1-3)</option>
<option value="neutral">Нейтральные (4-7)</option>
<option value="positive">Положительные (8-10)</option>
</select>
</div>
</div>
{/* Кнопки — под фильтрами, по правому краю */}
<div className="flex gap-3 flex-wrap justify-end items-center">
<select
value={fetchSource}
onChange={e => setFetchSource(e.target.value as 'yandex_maps' | '2gis' | '')}
className="px-4 py-3 bg-white border border-slate-200 rounded-2xl text-sm outline-none focus:ring-2 focus:ring-primary-500"
disabled={isFetching}
>
<option value="">Источник</option>
<option value="2gis">2ГИС</option>
<option value="yandex_maps">Яндекс Карты</option>
</select>
<button
onClick={handleFetchReviews}
disabled={isFetching || !fetchSource}
className="px-5 py-3 bg-indigo-600 text-white rounded-2xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg active:scale-95 disabled:bg-indigo-400 disabled:cursor-not-allowed transition-all"
>
{isFetching ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
{isFetching ? 'Загрузка...' : 'Загрузить отзывы'}
</button>
<button
onClick={() => setShowCreateForm(true)}
className="px-5 py-3 bg-primary-600 text-white rounded-2xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg active:scale-95 transition-all"
>
<Plus className="w-4 h-4" />
Добавить отзыв
</button>
<button
onClick={handleAnalyze}
disabled={isLoading || reviews.length === 0}
className="px-6 py-3 bg-slate-900 text-white rounded-2xl text-xs font-black uppercase tracking-widest flex items-center gap-2 shadow-lg active:scale-95 disabled:bg-slate-400 transition-all"
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{isLoading ? 'Анализ...' : 'Анализ отзывов'}
</button>
</div>
</div>
{/* AI Summary Card */}
{aiResult && !isLoading && (
<div className="bg-gradient-to-br from-slate-800 to-slate-900 text-white p-6 rounded-[2rem] shadow-xl animate-fade-in">
<h4 className="font-bold flex items-center gap-2 mb-4"><Bot className="w-5 h-5 text-primary-400" /> Сводный отчет</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-[11px] font-medium leading-relaxed">
<div>
<h5 className="font-black text-emerald-400 uppercase tracking-widest mb-3">Главные плюсы:</h5>
<ul className="space-y-2">
{aiResult.summary.positive.map((item, i) => <li key={i} className="flex gap-2"><span className="text-emerald-500"></span> {item}</li>)}
</ul>
</div>
<div>
<h5 className="font-black text-red-400 uppercase tracking-widest mb-3">Болевые точки:</h5>
<ul className="space-y-2">
{aiResult.summary.negative.map((item, i) => <li key={i} className="flex gap-2"><span className="text-red-500"></span> {item}</li>)}
</ul>
</div>
</div>
</div>
)}
{/* Create Review Form */}
{showCreateForm && (
<ReviewCreateForm
onClose={() => setShowCreateForm(false)}
onSuccess={() => {
setShowCreateForm(false);
loadReviews();
}}
/>
)}
{/* Feedback List */}
{isLoadingReviews ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>
) : reviewsServiceUnavailable ? (
<div className="text-center py-12 text-amber-600 bg-amber-50 rounded-2xl border border-amber-200">
<p className="font-medium">Сервис отзывов временно недоступен</p>
<p className="text-sm text-slate-500 mt-1">Проверьте подключение к n8n или повторите позже</p>
<button
onClick={() => loadReviews()}
className="mt-4 px-4 py-2 bg-amber-600 text-white rounded-xl text-xs font-black uppercase tracking-wider"
>
Повторить
</button>
</div>
) : analyzedFeedback.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<p>Отзывы не найдены</p>
<button
onClick={() => setShowCreateForm(true)}
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-xl text-xs font-black uppercase tracking-wider"
>
Добавить первый отзыв
</button>
</div>
) : (
<div className="space-y-3">
{analyzedFeedback.map(fb => (
<FeedbackCard
key={fb.id}
feedback={fb}
onStatusChange={handleStatusChange}
onCreateIncident={handleCreateIncident}
/>
))}
</div>
)}
</div>
);
};
// Функция для получения читаемого названия источника
const getSourceName = (source: string) => {
const sourceNames: Record<string, string> = {
'yandex_maps': 'Яндекс Карты',
'2gis': '2ГИС',
'internal': 'Внутренний',
'other': 'Другое'
};
return sourceNames[source] || source;
};
interface FeedbackCardProps {
feedback: AnalyzedFeedback;
onStatusChange: (reviewId: number, status: 'processed' | 'archived') => void;
onCreateIncident: (reviewId: number) => void;
}
const FeedbackCard: React.FC<FeedbackCardProps> = ({ feedback, onStatusChange, onCreateIncident }) => {
const [incident, setIncident] = useState<any>(null);
const [isLoadingIncident, setIsLoadingIncident] = useState(false);
const sentimentConfig = {
Positive: { icon: ThumbsUp, color: 'text-emerald-500', bg: 'bg-emerald-50' },
Negative: { icon: ThumbsDown, color: 'text-red-500', bg: 'bg-red-50' },
Neutral: { icon: MessageSquare, color: 'text-slate-500', bg: 'bg-slate-50' },
};
const config = sentimentConfig[feedback.sentiment];
const Icon = config.icon;
// Определяем статус из review (если есть)
const review = feedback as any;
const status = review.status || 'new';
const isNegative = feedback.rating <= 3;
const authorName = review.authorName || review.author_name;
const sourceUrl = review.sourceUrl || review.source_url;
const processedAt = review.processedAt || review.processed_at;
const processedBy = review.processedBy || review.processed_by;
// Загружаем инцидент, если есть
useEffect(() => {
const loadIncident = async () => {
try {
setIsLoadingIncident(true);
const incidents = await apiClient.get<any[]>(`/pr/incidents?review_id=${Number(feedback.id)}`);
if (incidents && incidents.length > 0) {
setIncident(incidents[0]);
}
} catch (err) {
// Игнорируем ошибки загрузки инцидента
console.warn('Error loading incident:', err);
} finally {
setIsLoadingIncident(false);
}
};
if (feedback.id && status === 'processed') {
loadIncident();
}
}, [feedback.id, status]);
return (
<div className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm hover:border-primary-300 transition-colors group">
<div className="flex gap-4">
<div className={`w-12 h-12 rounded-2xl flex-shrink-0 flex items-center justify-center ${config.bg}`}>
<Icon className={`w-6 h-6 ${config.color}`} />
</div>
<div className="flex-1">
<div className="flex items-center justify-between gap-2 flex-wrap mb-2">
<div>
<h5 className="font-black text-slate-800 text-sm">{feedback.address}</h5>
<p className="text-[10px] text-slate-400 font-bold mt-0.5 uppercase tracking-tighter">
{feedback.date} {getSourceName(feedback.source)}
{authorName && `${authorName}`}
{status === 'processed' && ' • Обработан'}
{status === 'archived' && ' • В архиве'}
{processedAt && `${new Date(processedAt).toLocaleDateString('ru-RU')}`}
{processedBy && `${processedBy}`}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-[9px] font-black text-primary-600 bg-primary-50 px-2 py-0.5 rounded-full uppercase tracking-tighter border border-primary-100">
{feedback.category}
</span>
<span className="text-xs font-black text-amber-600">
{feedback.rating}/10
</span>
</div>
</div>
<p className="text-sm text-slate-600 leading-relaxed font-medium mb-3">«{feedback.text}»</p>
{/* Индикатор инцидента */}
{incident && (
<div className="mb-3 p-2 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-xs text-amber-800 font-bold flex items-center gap-2">
<AlertCircle className="w-3 h-3" />
Инцидент создан: {incident.title}
<a
href={`#negative`}
onClick={(e) => {
e.preventDefault();
// TODO: Переход к инциденту
}}
className="text-amber-600 hover:underline ml-2"
>
Открыть
</a>
</p>
</div>
)}
{/* Ссылка на оригинал */}
{sourceUrl && (
<div className="mb-3">
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary-600 hover:text-primary-700 font-bold flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
Открыть оригинал отзыва
</a>
</div>
)}
{/* Действия */}
<div className="flex gap-2 flex-wrap">
{status === 'new' && (
<>
<button
onClick={() => onStatusChange(Number(feedback.id), 'processed')}
className="px-3 py-1.5 bg-emerald-50 text-emerald-600 rounded-xl text-[10px] font-black uppercase tracking-wider hover:bg-emerald-100 transition-all flex items-center gap-1"
>
<CheckCircle2 className="w-3 h-3" />
Обработано
</button>
{isNegative && (
<button
onClick={() => onCreateIncident(Number(feedback.id))}
className="px-3 py-1.5 bg-red-50 text-red-600 rounded-xl text-[10px] font-black uppercase tracking-wider hover:bg-red-100 transition-all flex items-center gap-1"
>
<AlertCircle className="w-3 h-3" />
Создать инцидент
</button>
)}
</>
)}
{status === 'processed' && (
<button
onClick={() => onStatusChange(Number(feedback.id), 'archived')}
className="px-3 py-1.5 bg-slate-100 text-slate-600 rounded-xl text-[10px] font-black uppercase tracking-wider hover:bg-slate-200 transition-all flex items-center gap-1"
>
<Archive className="w-3 h-3" />
В архив
</button>
)}
</div>
</div>
</div>
</div>
);
};
interface ReviewCreateFormProps {
onClose: () => void;
onSuccess: () => void;
}
const ReviewCreateForm: React.FC<ReviewCreateFormProps> = ({ onClose, onSuccess }) => {
const [formData, setFormData] = useState({
building_id: '',
source: 'internal' as Review['source'],
source_url: '',
author_name: '',
text: '',
rating: 5,
date: new Date().toISOString().split('T')[0]
});
const [buildings, setBuildings] = useState<Building[]>([]);
const [isLoadingBuildings, setIsLoadingBuildings] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
loadBuildings();
}, []);
const loadBuildings = async () => {
try {
setIsLoadingBuildings(true);
const data = await backendApi.getBuildings();
setBuildings(data);
} catch (err) {
console.error('Error loading buildings:', err);
setBuildings([]);
} finally {
setIsLoadingBuildings(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.building_id || !formData.text || !formData.date) {
alert('Заполните обязательные поля');
return;
}
try {
setIsSubmitting(true);
await apiClient.post('/pr/reviews', formData);
onSuccess();
} catch (err) {
console.error('Error creating review:', err);
alert('Ошибка создания отзыва');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-3xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-slate-800">Добавить отзыв</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-xl">
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Дом
</label>
{isLoadingBuildings ? (
<div className="w-full p-3 border border-slate-200 rounded-xl text-sm text-slate-400">
Загрузка домов...
</div>
) : (
<select
value={formData.building_id}
onChange={e => setFormData({ ...formData, building_id: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
>
<option value="">Выберите дом</option>
{buildings.map(building => (
<option key={building.id} value={building.id}>
{building.passport?.address || building.id}
</option>
))}
</select>
)}
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Источник
</label>
<select
value={formData.source}
onChange={e => setFormData({ ...formData, source: e.target.value as Review['source'] })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
>
<option value="internal">Внутренний</option>
<option value="yandex_maps">Яндекс Карты</option>
<option value="2gis">2ГИС</option>
<option value="other">Другое</option>
</select>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Ссылка на отзыв (опционально)
</label>
<input
type="text"
value={formData.source_url}
onChange={e => setFormData({ ...formData, source_url: e.target.value })}
placeholder="https://yandex.ru/maps/org/..."
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
/>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Автор (опционально)
</label>
<input
type="text"
value={formData.author_name}
onChange={e => setFormData({ ...formData, author_name: e.target.value })}
placeholder="Имя автора отзыва"
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
/>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Текст отзыва
</label>
<textarea
value={formData.text}
onChange={e => setFormData({ ...formData, text: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm resize-none"
rows={4}
placeholder="Текст отзыва..."
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Рейтинг (1-10)
</label>
<input
type="number"
value={formData.rating}
onChange={e => setFormData({ ...formData, rating: parseInt(e.target.value) || 5 })}
min="1"
max="10"
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
/>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Дата
</label>
<input
type="date"
value={formData.date}
onChange={e => setFormData({ ...formData, date: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
/>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-6 py-3 bg-primary-600 text-white rounded-xl text-xs font-black uppercase tracking-widest disabled:opacity-50"
>
{isSubmitting ? 'Создание...' : 'Создать отзыв'}
</button>
<button
type="button"
onClick={onClose}
className="px-6 py-3 bg-slate-100 text-slate-600 rounded-xl text-xs font-black uppercase tracking-widest"
>
Отмена
</button>
</div>
</form>
</div>
</div>
);
};

200
components/pr/PRSummary.tsx Executable file
View File

@@ -0,0 +1,200 @@
import React, { useState, useEffect } from 'react';
import { Smile, Frown, Meh, TrendingUp, TrendingDown, Target, Heart, Share2, Send, MessageSquare, Hash, PartyPopper, Calendar } from 'lucide-react';
import { MOCK_BUILDINGS } from '../../constants';
import { backendApi } from '../../services/apiClient';
import type { SMMChannel, PREvent } from '../../types';
const CHANNEL_ICONS: Record<string, typeof Send> = { tg: Send, vk: Share2, wa: MessageSquare, other: Hash };
interface Props {
onNavigate: (tab: any) => void;
}
export const PRSummary: React.FC<Props> = ({ onNavigate }) => {
const avgNPS = 68;
const sentiment = { positive: 72, neutral: 18, negative: 10 };
const [smmChannels, setSmmChannels] = useState<SMMChannel[]>([]);
const [smmLoading, setSmmLoading] = useState(true);
const [events, setEvents] = useState<PREvent[]>([]);
const [eventsLoading, setEventsLoading] = useState(true);
useEffect(() => {
setSmmLoading(true);
backendApi.getSMMChannels()
.then((list) => setSmmChannels(Array.isArray(list) ? list : []))
.catch(() => setSmmChannels([]))
.finally(() => setSmmLoading(false));
}, []);
useEffect(() => {
setEventsLoading(true);
const now = new Date();
const from = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
const to = new Date(now.getFullYear(), now.getMonth() + 2, 0).toISOString().slice(0, 10);
backendApi.getPREvents({ from, to, limit: 30 })
.then((list) => setEvents(Array.isArray(list) ? list : []))
.catch(() => setEvents([]))
.finally(() => setEventsLoading(false));
}, []);
return (
<div className="space-y-6 animate-fade-in">
{/* NPS Card */}
<div className="bg-slate-900 rounded-[2.5rem] p-8 text-white shadow-xl relative overflow-hidden">
<Heart className="absolute -top-4 -right-4 w-48 h-48 opacity-10 rotate-12 text-primary-400" />
<div className="relative z-10">
<div className="flex justify-between items-start mb-12">
<div>
<h3 className="text-3xl font-black mb-2">Net Promoter Score</h3>
<p className="text-slate-400 text-sm font-medium">Общий индекс лояльности по компании</p>
</div>
<div className="text-right">
<p className="text-6xl font-black text-emerald-400">+{avgNPS}</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500 mt-1">Тенденция: Рост</p>
</div>
</div>
<div className="grid grid-cols-3 gap-6">
<SentimentWidget icon={Smile} label="Промоутеры" value={sentiment.positive} color="text-emerald-400" />
<SentimentWidget icon={Meh} label="Нейтралы" value={sentiment.neutral} color="text-slate-400" />
<SentimentWidget icon={Frown} label="Критики" value={sentiment.negative} color="text-red-400" />
</div>
</div>
</div>
{/* Performance Lists */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-[2rem] p-6 border border-slate-200 shadow-sm">
<h4 className="font-black text-emerald-600 text-[10px] uppercase tracking-widest mb-4 flex items-center gap-2">
<TrendingUp className="w-4 h-4"/> Топ лояльных домов
</h4>
<div className="space-y-3">
{MOCK_BUILDINGS.filter(b => b.nps > 30).slice(0, 3).map(b => (
<div key={b.id} className="flex items-center justify-between p-3 bg-emerald-50/50 rounded-2xl border border-emerald-100">
<span className="text-xs font-bold text-slate-700 truncate mr-2">{b.passport.address}</span>
<span className="px-2 py-0.5 rounded-lg text-[10px] font-black bg-emerald-100 text-emerald-600">+{b.nps} NPS</span>
</div>
))}
</div>
</div>
<div className="bg-white rounded-[2rem] p-6 border border-slate-200 shadow-sm">
<h4 className="font-black text-red-600 text-[10px] uppercase tracking-widest mb-4 flex items-center gap-2">
<TrendingDown className="w-4 h-4"/> Зоны внимания (Критики)
</h4>
<div className="space-y-3">
{MOCK_BUILDINGS.filter(b => b.nps < 10).slice(0, 3).map(b => (
<div key={b.id} className="flex items-center justify-between p-3 bg-red-50/50 rounded-2xl border border-red-100">
<span className="text-xs font-bold text-slate-700 truncate mr-2">{b.passport.address}</span>
<span className="px-2 py-0.5 rounded-lg text-[10px] font-black bg-red-100 text-red-600">{b.nps} NPS</span>
</div>
))}
</div>
</div>
</div>
{/* SMM: соцсети и подписчики */}
<div className="bg-white rounded-[2rem] p-6 border border-slate-200 shadow-sm">
<h4 className="font-black text-slate-600 text-[10px] uppercase tracking-widest mb-4 flex items-center gap-2">
<Share2 className="w-4 h-4 text-primary-500"/> SMM соцсети и подписчики
</h4>
{smmLoading ? (
<p className="text-sm text-slate-400">Загрузка...</p>
) : smmChannels.length === 0 ? (
<div className="py-4 text-center">
<p className="text-sm text-slate-500 mb-2">Каналы не добавлены</p>
<button type="button" onClick={() => onNavigate('smm')} className="text-primary-600 text-xs font-bold uppercase hover:underline">Добавить каналы в SMM</button>
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{smmChannels.map(ch => {
const Icon = CHANNEL_ICONS[ch.type] || Hash;
const count = ch.subscribersCount ?? ch.lastSnapshot?.subscribersCount;
const style = ch.type === 'tg' ? 'text-sky-500 bg-sky-50' : ch.type === 'vk' ? 'text-blue-600 bg-blue-50' : ch.type === 'wa' ? 'text-emerald-500 bg-emerald-50' : 'text-slate-600 bg-slate-50';
return (
<div key={ch.id} className="flex items-center gap-4 p-4 rounded-2xl border border-slate-100 bg-slate-50/50">
<div className={`w-12 h-12 rounded-xl ${style} flex items-center justify-center shrink-0`}>
<Icon className="w-6 h-6"/>
</div>
<div className="min-w-0">
<p className="text-xs font-bold text-slate-800 truncate">{ch.name}</p>
<p className="text-[11px] font-black text-slate-600">{count != null ? `${count.toLocaleString('ru-RU')} подписчиков` : '—'}</p>
</div>
</div>
);
})}
</div>
<p className="text-[10px] text-slate-400 font-bold mt-4 px-1">
Всего: <span className="text-slate-700 font-black">{smmChannels.reduce((s, c) => s + (c.subscribersCount ?? c.lastSnapshot?.subscribersCount ?? 0), 0).toLocaleString('ru-RU')}</span> подписчиков
</p>
</>
)}
</div>
{/* Мероприятия */}
<div className="bg-white rounded-[2rem] p-6 border border-slate-200 shadow-sm">
<h4 className="font-black text-slate-600 text-[10px] uppercase tracking-widest mb-4 flex items-center gap-2">
<PartyPopper className="w-4 h-4 text-primary-500"/> Ближайшие мероприятия
</h4>
{eventsLoading ? (
<p className="text-sm text-slate-400">Загрузка...</p>
) : events.length === 0 ? (
<div className="py-4 text-center">
<p className="text-sm text-slate-500 mb-2">Нет запланированных мероприятий</p>
<button type="button" onClick={() => onNavigate('events')} className="text-primary-600 text-xs font-bold uppercase hover:underline">Перейти к реестру</button>
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-4">
<div className="p-3 bg-primary-50 rounded-xl border border-primary-100">
<p className="text-[10px] font-bold uppercase text-primary-600">Запланировано</p>
<p className="text-xl font-black text-primary-800">{events.filter(e => e.status === 'planned' || e.status === 'in_progress').length}</p>
</div>
<div className="p-3 bg-emerald-50 rounded-xl border border-emerald-100">
<p className="text-[10px] font-bold uppercase text-emerald-600">Проведено</p>
<p className="text-xl font-black text-emerald-800">{events.filter(e => e.status === 'completed').length}</p>
</div>
</div>
<div className="space-y-2">
{events.filter(e => e.status === 'planned' || e.status === 'in_progress').slice(0, 5).map((ev) => (
<div key={String(ev.id)} className="flex items-center justify-between p-3 bg-slate-50 rounded-xl border border-slate-100">
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-slate-800 truncate">{ev.title}</p>
<p className="text-[10px] text-slate-500 flex items-center gap-1">
<Calendar className="w-3 h-3"/> {typeof ev.date === 'string' ? ev.date : String(ev.date)}
</p>
</div>
<button type="button" onClick={() => onNavigate('events')} className="text-primary-600 text-[10px] font-bold uppercase shrink-0">Открыть</button>
</div>
))}
</div>
<button type="button" onClick={() => onNavigate('events')} className="w-full mt-4 py-2 text-primary-600 text-xs font-bold uppercase hover:underline">
Перейти к реестру мероприятий
</button>
</>
)}
</div>
<button onClick={() => onNavigate('negative')} className="w-full py-4 bg-slate-900 text-white rounded-3xl font-black text-xs uppercase tracking-widest flex items-center justify-center gap-3 shadow-xl active:scale-95 transition-all">
<Target className="w-5 h-5 text-red-400"/> Отработать 2 критических отзыва
</button>
</div>
);
};
const SentimentWidget = ({ icon: Icon, label, value, color }: any) => (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Icon className={`w-4 h-4 ${color}`}/>
<span className="text-[10px] font-black uppercase text-slate-500">{label}</span>
</div>
<div className="flex items-end gap-2">
<p className="text-2xl font-black leading-none">{value}%</p>
<div className="flex-1 h-1 bg-white/10 rounded-full overflow-hidden mb-1">
<div className={`h-full ${color.replace('text-', 'bg-')} rounded-full`} style={{ width: `${value}%` }} />
</div>
</div>
</div>
);

View File

@@ -0,0 +1,393 @@
import React, { useState, useEffect } from 'react';
import { Plus, X, CheckCircle, XCircle, Calendar, Pencil, Trash2, Eye, FileText } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
import type { PostTopic } from '../../types';
interface PostTopicsManagerProps {
onCreatePostFromTopic?: (topic: PostTopic) => void;
}
export const PostTopicsManager: React.FC<PostTopicsManagerProps> = ({ onCreatePostFromTopic }) => {
const [list, setList] = useState<PostTopic[]>([]);
const [loading, setLoading] = useState(true);
const [selectedMonth, setSelectedMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
});
const [modalOpen, setModalOpen] = useState(false);
const [viewModal, setViewModal] = useState<PostTopic | null>(null);
const [editing, setEditing] = useState<PostTopic | null>(null);
const [form, setForm] = useState({ title: '', description: '', scheduledDate: '', month: selectedMonth });
const [saving, setSaving] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const loadList = () => {
setLoading(true);
backendApi.getPostTopics({ month: selectedMonth, limit: 100 })
.then((data) => setList(Array.isArray(data) ? data : []))
.catch(() => setList([]))
.finally(() => setLoading(false));
};
useEffect(() => {
loadList();
}, [selectedMonth]);
const openCreate = () => {
setEditing(null);
const firstDayOfMonth = `${selectedMonth}-01`;
setForm({ title: '', description: '', scheduledDate: firstDayOfMonth, month: selectedMonth });
setModalOpen(true);
};
const openEdit = (topic: PostTopic) => {
setEditing(topic);
setForm({
title: topic.title,
description: topic.description ?? '',
scheduledDate: topic.scheduledDate ? topic.scheduledDate.slice(0, 10) : `${topic.month}-01`,
month: topic.month
});
setModalOpen(true);
};
const handleSave = () => {
const title = form.title.trim();
if (!title || !form.scheduledDate) return;
setSaving(true);
const monthVal = form.month || form.scheduledDate.slice(0, 7);
if (editing) {
backendApi.updatePostTopic(editing.id, {
title,
description: form.description.trim() || undefined,
scheduledDate: form.scheduledDate
})
.then(() => { setModalOpen(false); loadList(); })
.finally(() => setSaving(false));
} else {
backendApi.createPostTopic({
title,
description: form.description.trim() || undefined,
scheduledDate: form.scheduledDate,
month: monthVal
})
.then(() => { setModalOpen(false); loadList(); })
.finally(() => setSaving(false));
}
};
const handleApprove = (id: number) => {
if (!confirm('Одобрить тему графика публикации?')) return;
setActionLoading(true);
backendApi.approvePostTopic(id)
.then(() => { setViewModal(null); loadList(); })
.finally(() => setActionLoading(false));
};
const handleReject = (id: number) => {
const reason = prompt('Укажите причину отклонения:');
if (!reason) return;
setActionLoading(true);
backendApi.rejectPostTopic(id, { rejectionReason: reason })
.then(() => { setViewModal(null); loadList(); })
.finally(() => setActionLoading(false));
};
const handleSendToApproval = (id: number) => {
setActionLoading(true);
backendApi.updatePostTopic(id, { status: 'pending_approval' })
.then(() => { setModalOpen(false); loadList(); })
.finally(() => setActionLoading(false));
};
const handleDelete = (id: number) => {
if (!confirm('Удалить тему из графика?')) return;
backendApi.deletePostTopic(id).then(() => loadList());
};
const getStatusColor = (status: PostTopic['status']) => {
switch (status) {
case 'approved': return 'text-emerald-600 bg-emerald-50';
case 'pending_approval': return 'text-amber-600 bg-amber-50';
case 'rejected': return 'text-red-600 bg-red-50';
default: return 'text-slate-500 bg-slate-50';
}
};
const getStatusLabel = (status: PostTopic['status']) => {
switch (status) {
case 'draft': return 'Черновик';
case 'pending_approval': return 'На согласовании';
case 'approved': return 'Одобрено';
case 'rejected': return 'Отклонено';
default: return status;
}
};
const pendingCount = list.filter(t => t.status === 'pending_approval').length;
const approvedCount = list.filter(t => t.status === 'approved').length;
const approvedTopics = list.filter(t => t.status === 'approved');
// Группировка по датам для календарного вида
const topicsByDate: Record<string, PostTopic[]> = {};
list.forEach(topic => {
const date = topic.scheduledDate ? topic.scheduledDate.slice(0, 10) : `${topic.month}-01`;
if (!topicsByDate[date]) topicsByDate[date] = [];
topicsByDate[date].push(topic);
});
return (
<div className="space-y-6 animate-fade-in">
<div className="flex justify-between items-center">
<div>
<h3 className="font-black text-slate-800 text-sm">График публикации</h3>
<p className="text-[10px] text-slate-500 uppercase tracking-widest mt-0.5">
План публикаций на месяц (без контента). По этим темам создаются посты с контентом для одобрения.
</p>
</div>
<button
type="button"
onClick={openCreate}
className="flex items-center gap-2 px-4 py-2.5 bg-primary-600 text-white rounded-xl text-xs font-bold uppercase tracking-wider hover:bg-primary-700"
>
<Plus className="w-4 h-4" /> Добавить в график
</button>
</div>
<div className="flex items-center gap-4">
<label className="text-[10px] font-bold uppercase text-slate-500">Месяц</label>
<input
type="month"
value={selectedMonth}
onChange={(e) => setSelectedMonth(e.target.value)}
className="px-4 py-2 border border-slate-200 rounded-xl text-sm"
/>
</div>
{(pendingCount > 0 || approvedCount > 0) && (
<div className="grid grid-cols-2 gap-4">
{pendingCount > 0 && (
<div className="p-4 bg-amber-50 rounded-2xl border border-amber-100">
<p className="text-[10px] font-bold uppercase text-amber-600 tracking-widest">На согласовании</p>
<p className="text-2xl font-black text-amber-800">{pendingCount}</p>
</div>
)}
{approvedCount > 0 && (
<div className="p-4 bg-emerald-50 rounded-2xl border border-emerald-100">
<p className="text-[10px] font-bold uppercase text-emerald-600 tracking-widest">Одобрено</p>
<p className="text-2xl font-black text-emerald-800">{approvedCount}</p>
</div>
)}
</div>
)}
{loading ? (
<div className="py-8 text-center text-slate-400 text-sm">Загрузка...</div>
) : list.length === 0 ? (
<div className="py-12 text-center bg-white rounded-2xl border border-slate-200">
<p className="text-slate-500 text-sm mb-2">Нет записей в графике для выбранного месяца</p>
<p className="text-xs text-slate-400 mb-4">Создайте график публикаций на месяц, затем по этим темам создавайте посты с контентом</p>
<button type="button" onClick={openCreate} className="px-4 py-2 bg-primary-600 text-white rounded-xl text-xs font-bold">Добавить в график</button>
</div>
) : (
<div className="space-y-4">
{/* Календарное отображение */}
{Object.keys(topicsByDate).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">
{topicsByDate[date].map((topic) => (
<div
key={topic.id}
className="flex items-start justify-between gap-3 p-3 bg-slate-50 rounded-xl border border-slate-100"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className="font-bold text-slate-800 text-sm">{topic.title}</span>
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded ${getStatusColor(topic.status)}`}>
{getStatusLabel(topic.status)}
</span>
</div>
{topic.description && <p className="text-xs text-slate-600 line-clamp-1">{topic.description}</p>}
</div>
<div className="flex items-center gap-1 shrink-0 flex-wrap">
{topic.status === 'approved' && onCreatePostFromTopic && (
<button
type="button"
onClick={() => onCreatePostFromTopic(topic)}
className="px-3 py-1.5 bg-primary-50 text-primary-600 rounded-lg text-[10px] font-bold uppercase flex items-center gap-1 whitespace-nowrap"
title="Создать пост по этой теме"
>
<FileText className="w-3.5 h-3.5" /> Создать пост
</button>
)}
<button type="button" onClick={() => setViewModal(topic)} className="p-1.5 text-slate-400 hover:text-primary-600 rounded" title="Просмотр">
<Eye className="w-4 h-4" />
</button>
{topic.status === 'draft' && (
<>
<button type="button" onClick={() => openEdit(topic)} className="p-1.5 text-slate-400 hover:text-primary-600 rounded" title="Изменить">
<Pencil className="w-4 h-4" />
</button>
<button type="button" onClick={() => handleSendToApproval(topic.id)} disabled={actionLoading} className="px-2 py-1 bg-amber-50 text-amber-600 rounded-lg text-[9px] font-bold uppercase" title="Отправить на согласование">
На согласование
</button>
</>
)}
{topic.status === 'pending_approval' && (
<>
<button type="button" onClick={() => handleApprove(topic.id)} disabled={actionLoading} className="px-2 py-1 bg-emerald-50 text-emerald-600 rounded-lg text-[9px] font-bold uppercase flex items-center gap-1" title="Одобрить">
<CheckCircle className="w-3 h-3" /> Одобрить
</button>
<button type="button" onClick={() => handleReject(topic.id)} disabled={actionLoading} className="px-2 py-1 bg-red-50 text-red-600 rounded-lg text-[9px] font-bold uppercase flex items-center gap-1" title="Отклонить">
<XCircle className="w-3 h-3" /> Отклонить
</button>
</>
)}
<button type="button" onClick={() => handleDelete(topic.id)} className="p-1.5 text-slate-400 hover:text-red-600 rounded" title="Удалить">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* Create/Edit Modal */}
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => !saving && setModalOpen(false)}>
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-center mb-4">
<h3 className="font-black text-slate-800">{editing ? 'Редактировать тему графика' : 'Добавить в график публикации'}</h3>
<button type="button" onClick={() => !saving && setModalOpen(false)} className="p-1 text-slate-400 hover:text-slate-600"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-4">
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Тема публикации *</label>
<input
type="text"
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
placeholder="Напр.: Отчёт о работе за месяц"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm"
/>
</div>
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Описание (необязательно)</label>
<textarea
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="Краткое описание темы"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm resize-none"
rows={3}
/>
</div>
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Дата планируемой публикации *</label>
<input
type="date"
value={form.scheduledDate}
onChange={(e) => {
const date = e.target.value;
setForm((f) => ({ ...f, scheduledDate: date, month: date.slice(0, 7) }));
}}
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm"
/>
</div>
</div>
<div className="flex gap-2 justify-end mt-6">
<button type="button" onClick={() => !saving && setModalOpen(false)} className="px-4 py-2 text-slate-600 font-bold text-sm">Отмена</button>
<button type="button" onClick={handleSave} disabled={saving || !form.title.trim() || !form.scheduledDate} className="px-4 py-2 bg-primary-600 text-white rounded-xl font-bold text-sm disabled:opacity-50">
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
</div>
)}
{/* View Modal */}
{viewModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => setViewModal(null)}>
<div className="bg-white rounded-2xl shadow-xl max-w-md w-full p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-center mb-4">
<h3 className="font-black text-slate-800">Просмотр темы графика</h3>
<button type="button" onClick={() => setViewModal(null)} className="p-1 text-slate-400 hover:text-slate-600"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-3">
<div>
<p className="text-[10px] font-bold uppercase text-slate-500 mb-1">Тема</p>
<p className="text-base font-bold text-slate-800">{viewModal.title}</p>
</div>
{viewModal.description && (
<div>
<p className="text-[10px] font-bold uppercase text-slate-500 mb-1">Описание</p>
<p className="text-sm text-slate-700">{viewModal.description}</p>
</div>
)}
<div className="flex gap-2 flex-wrap">
<span className={`text-[10px] font-bold px-2 py-1 rounded ${getStatusColor(viewModal.status)}`}>
{getStatusLabel(viewModal.status)}
</span>
<span className="text-[10px] text-slate-500 flex items-center gap-1">
<Calendar className="w-3.5 h-3.5" /> {viewModal.scheduledDate ? new Date(viewModal.scheduledDate).toLocaleDateString('ru-RU') : viewModal.month}
</span>
</div>
{viewModal.rejectionReason && (
<div className="bg-red-50 border border-red-200 rounded-xl p-3">
<p className="text-[10px] font-bold uppercase text-red-600 mb-1">Причина отклонения</p>
<p className="text-sm text-red-700">{viewModal.rejectionReason}</p>
</div>
)}
</div>
{viewModal.status === 'pending_approval' && (
<div className="flex gap-2 mt-6 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => handleApprove(viewModal.id)}
disabled={actionLoading}
className="flex-1 px-4 py-2.5 bg-emerald-600 text-white rounded-xl font-bold text-sm disabled:opacity-50 flex items-center justify-center gap-2"
>
<CheckCircle className="w-4 h-4" /> Одобрить
</button>
<button
type="button"
onClick={() => {
const reason = prompt('Укажите причину отклонения:');
if (reason) handleReject(viewModal.id);
}}
disabled={actionLoading}
className="px-4 py-2.5 bg-red-600 text-white rounded-xl font-bold text-sm disabled:opacity-50 flex items-center gap-2"
>
<XCircle className="w-4 h-4" /> Отклонить
</button>
</div>
)}
{viewModal.status === 'approved' && onCreatePostFromTopic && (
<div className="mt-6 pt-4 border-t border-slate-200">
<button
type="button"
onClick={() => {
onCreatePostFromTopic(viewModal);
setViewModal(null);
}}
className="w-full px-4 py-2.5 bg-primary-600 text-white rounded-xl font-bold text-sm flex items-center justify-center gap-2"
>
<FileText className="w-4 h-4" /> Создать пост по этой теме
</button>
</div>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,456 @@
import React, { useState, useEffect } from 'react';
import { Plus, X, CheckCircle, XCircle, Calendar, Pencil, Trash2, Eye, FileText, Image as ImageIcon, Upload, Edit } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
import type { ScheduledPost, SMMChannel } from '../../types';
export const PublicationSchedule: React.FC = () => {
const [selectedMonth, setSelectedMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
});
// Посты (отложенные)
const [posts, setPosts] = useState<ScheduledPost[]>([]);
const [postsLoading, setPostsLoading] = useState(false);
// Каналы для постов
const [channels, setChannels] = useState<SMMChannel[]>([]);
// Модалки
const [postModal, setPostModal] = useState(false);
const [viewPostModal, setViewPostModal] = useState<ScheduledPost | null>(null);
// Формы
const [postForm, setPostForm] = useState({
title: '',
content: '',
channelIds: [] as number[],
scheduledAt: '',
image: null as File | null,
imagePreview: '' as string | ''
});
const [editingPost, setEditingPost] = useState<ScheduledPost | null>(null);
const [saving, setSaving] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [editContent, setEditContent] = useState('');
useEffect(() => {
loadPosts();
loadChannels();
}, [selectedMonth]);
const loadPosts = () => {
setPostsLoading(true);
const from = `${selectedMonth}-01T00:00:00`;
const to = new Date(new Date(selectedMonth + '-01').setMonth(new Date(selectedMonth + '-01').getMonth() + 1)).toISOString();
backendApi.getScheduledPosts({ from, to, limit: 100 })
.then((data) => setPosts(Array.isArray(data) ? data : []))
.catch(() => setPosts([]))
.finally(() => setPostsLoading(false));
};
const loadChannels = () => {
backendApi.getSMMChannels()
.then((data) => setChannels(Array.isArray(data) ? data : []))
.catch(() => setChannels([]));
};
const openPostCreate = () => {
setEditingPost(null);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(10, 0, 0, 0);
setPostForm({
title: '',
content: '',
channelIds: [],
scheduledAt: tomorrow.toISOString().slice(0, 16),
image: null,
imagePreview: ''
});
setPostModal(true);
};
const openPostEdit = (post: ScheduledPost) => {
setEditingPost(post);
setPostForm({
title: post.title,
content: post.content,
channelIds: post.channelIds || [],
scheduledAt: new Date(post.scheduledAt).toISOString().slice(0, 16),
image: null,
imagePreview: post.imageUrl || ''
});
setPostModal(true);
};
const savePost = () => {
const title = postForm.title.trim();
const content = postForm.content.trim();
if (!title || !content || !postForm.scheduledAt) return;
setSaving(true);
if (editingPost) {
backendApi.updateScheduledPost(editingPost.id, {
title,
content,
channelIds: postForm.channelIds,
scheduledAt: postForm.scheduledAt,
image: postForm.image || undefined,
removeImage: !postForm.image && !postForm.imagePreview ? true : undefined
})
.then(() => { setPostModal(false); loadPosts(); })
.finally(() => setSaving(false));
} else {
backendApi.createScheduledPost({
title,
content,
channelIds: postForm.channelIds,
scheduledAt: postForm.scheduledAt,
status: 'pending_approval', // Сразу отправляем на утверждение
image: postForm.image || undefined
})
.then(() => { setPostModal(false); loadPosts(); })
.finally(() => setSaving(false));
}
};
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setPostForm(f => ({ ...f, image: file, imagePreview: URL.createObjectURL(file) }));
}
};
const handleApprovePost = (id: number) => {
setActionLoading(true);
backendApi.approveScheduledPost(id)
.then(() => { setViewPostModal(null); loadPosts(); })
.finally(() => setActionLoading(false));
};
const handleRejectPost = (id: number) => {
const reason = prompt('Укажите причину отклонения:');
if (!reason) return;
setActionLoading(true);
backendApi.rejectScheduledPost(id, { rejectionReason: reason })
.then(() => { setViewPostModal(null); loadPosts(); })
.finally(() => setActionLoading(false));
};
const handleSendToEdit = (id: number) => {
if (!editContent.trim()) return;
setActionLoading(true);
backendApi.sendScheduledPostToEdit(id, { editedContent: editContent })
.then(() => { setViewPostModal(null); setEditContent(''); loadPosts(); })
.finally(() => setActionLoading(false));
};
const handleDeletePost = (id: number) => {
if (!confirm('Удалить пост?')) return;
backendApi.deleteScheduledPost(id).then(() => loadPosts());
};
const getStatusColor = (status: string) => {
switch (status) {
case 'approved': return 'text-emerald-600 bg-emerald-50';
case 'pending_approval': return 'text-amber-600 bg-amber-50';
case 'rejected': return 'text-red-600 bg-red-50';
case 'published': return 'text-blue-600 bg-blue-50';
case 'edited': return 'text-purple-600 bg-purple-50';
default: return 'text-slate-500 bg-slate-50';
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'draft': return 'Черновик';
case 'pending_approval': return 'На согласовании';
case 'approved': return 'Одобрено';
case 'rejected': return 'Отклонено';
case 'edited': return 'На редактировании';
case 'published': return 'Опубликовано';
default: return status;
}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<label className="text-[10px] font-bold uppercase text-slate-500">Месяц</label>
<input
type="month"
value={selectedMonth}
onChange={(e) => setSelectedMonth(e.target.value)}
className="px-4 py-2 border border-slate-200 rounded-xl text-sm"
/>
</div>
<button
type="button"
onClick={openPostCreate}
className="flex items-center gap-2 px-4 py-2.5 bg-primary-600 text-white rounded-xl text-xs font-bold uppercase tracking-wider hover:bg-primary-700"
>
<Plus className="w-4 h-4" /> Создать пост
</button>
</div>
{/* Список отложенных постов */}
{postsLoading ? (
<div className="py-8 text-center text-slate-400 text-sm">Загрузка...</div>
) : posts.length === 0 ? (
<div className="py-12 text-center bg-white rounded-2xl border border-slate-200">
<p className="text-slate-500 text-sm mb-2">Нет отложенных постов для выбранного месяца</p>
<button type="button" onClick={openPostCreate} className="px-4 py-2 bg-primary-600 text-white rounded-xl text-xs font-bold">Создать пост</button>
</div>
) : (
<div className="space-y-3">
{posts.map((post) => (
<div key={post.id} className="group cursor-pointer border border-slate-100 rounded-xl p-3 hover:border-primary-200 transition-colors bg-white" onClick={() => setViewPostModal(post)}>
<div className="flex justify-between items-start mb-1">
<span className="text-[9px] font-black text-slate-400 uppercase">
{new Date(post.scheduledAt).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
</span>
<span className={`text-[8px] font-bold px-1.5 py-0.5 rounded ${getStatusColor(post.status)}`}>
{getStatusLabel(post.status)}
</span>
</div>
<p className="text-xs font-bold text-slate-700 group-hover:text-primary-600 transition-colors truncate">{post.title}</p>
{post.channelIds && post.channelIds.length > 0 && (
<p className="text-[8px] font-black text-primary-500 uppercase mt-1">
{post.channelIds.length} канал{post.channelIds.length > 1 ? 'ов' : ''}
</p>
)}
{post.imageUrl && (
<div className="mt-2">
<ImageIcon className="w-4 h-4 text-slate-400 inline" />
<span className="text-[8px] text-slate-500 ml-1">Есть изображение</span>
</div>
)}
</div>
))}
</div>
)}
{/* Модалка создания/редактирования поста */}
{postModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => !saving && setPostModal(false)}>
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-center mb-4">
<h3 className="font-black text-slate-800">{editingPost ? 'Редактировать пост' : 'Создать отложенный пост'}</h3>
<button type="button" onClick={() => !saving && setPostModal(false)} className="p-1 text-slate-400 hover:text-slate-600"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-4">
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Тема поста *</label>
<input
type="text"
value={postForm.title}
onChange={(e) => setPostForm((f) => ({ ...f, title: e.target.value }))}
placeholder="Например: Отчёт о работе за месяц"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm"
/>
</div>
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Дата и время публикации *</label>
<input
type="datetime-local"
value={postForm.scheduledAt}
onChange={(e) => setPostForm((f) => ({ ...f, scheduledAt: e.target.value }))}
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm"
/>
</div>
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Содержание *</label>
<textarea
value={postForm.content}
onChange={(e) => setPostForm((f) => ({ ...f, content: e.target.value }))}
placeholder="Текст поста"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm resize-none"
rows={8}
/>
</div>
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Изображение (необязательно)</label>
<div className="space-y-2">
{postForm.imagePreview && (
<div className="relative">
<img src={postForm.imagePreview} alt="Preview" className="max-w-xs rounded-lg border border-slate-200" />
<button
type="button"
onClick={() => setPostForm(f => ({ ...f, image: null, imagePreview: '' }))}
className="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full"
>
<X className="w-4 h-4" />
</button>
</div>
)}
<label className="flex items-center gap-2 px-4 py-2.5 border border-slate-200 rounded-xl cursor-pointer hover:bg-slate-50">
<Upload className="w-4 h-4 text-slate-400" />
<span className="text-sm text-slate-600">Загрузить изображение</span>
<input type="file" accept="image/*" onChange={handleImageChange} className="hidden" />
</label>
</div>
</div>
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Каналы</label>
<div className="space-y-2">
{channels.map((ch) => (
<label key={ch.id} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={postForm.channelIds.includes(ch.id)}
onChange={(e) => {
if (e.target.checked) {
setPostForm((f) => ({ ...f, channelIds: [...f.channelIds, ch.id] }));
} else {
setPostForm((f) => ({ ...f, channelIds: f.channelIds.filter(id => id !== ch.id) }));
}
}}
className="w-4 h-4 text-primary-600 rounded"
/>
<span className="text-sm text-slate-700">{ch.name}</span>
</label>
))}
</div>
</div>
</div>
<div className="flex gap-2 justify-end mt-6">
<button type="button" onClick={() => !saving && setPostModal(false)} className="px-4 py-2 text-slate-600 font-bold text-sm">Отмена</button>
<button type="button" onClick={savePost} disabled={saving || !postForm.title.trim() || !postForm.content.trim() || !postForm.scheduledAt} className="px-4 py-2 bg-primary-600 text-white rounded-xl font-bold text-sm disabled:opacity-50">
{saving ? 'Сохранение...' : editingPost ? 'Сохранить изменения' : 'Создать и отправить на утверждение'}
</button>
</div>
</div>
</div>
)}
{/* Модалка просмотра/одобрения поста */}
{viewPostModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={() => !actionLoading && setViewPostModal(null)}>
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-center mb-4">
<h3 className="font-black text-slate-800">Просмотр поста</h3>
<button type="button" onClick={() => !actionLoading && setViewPostModal(null)} className="p-1 text-slate-400 hover:text-slate-600"><X className="w-5 h-5" /></button>
</div>
<div className="space-y-4 mb-6">
<div>
<p className="text-[10px] font-bold uppercase text-slate-500 mb-1">Тема</p>
<p className="text-base font-bold text-slate-800">{viewPostModal.title}</p>
</div>
<div>
<p className="text-[10px] font-bold uppercase text-slate-500 mb-1">Дата публикации</p>
<p className="text-sm text-slate-700">{new Date(viewPostModal.scheduledAt).toLocaleString('ru-RU')}</p>
</div>
<div>
<p className="text-[10px] font-bold uppercase text-slate-500 mb-1">Содержание</p>
<div className="bg-slate-50 rounded-xl p-4 text-sm text-slate-700 whitespace-pre-wrap">{viewPostModal.content}</div>
</div>
{viewPostModal.imageUrl && (
<div>
<p className="text-[10px] font-bold uppercase text-slate-500 mb-1">Изображение</p>
<img src={viewPostModal.imageUrl} alt={viewPostModal.title} className="max-w-full rounded-lg border border-slate-200" />
</div>
)}
{viewPostModal.channelIds && viewPostModal.channelIds.length > 0 && (
<div>
<p className="text-[10px] font-bold uppercase text-slate-500 mb-1">Каналы</p>
<div className="flex gap-2 flex-wrap">
{channels.filter(ch => viewPostModal.channelIds?.includes(ch.id)).map(ch => (
<span key={ch.id} className="px-2 py-1 bg-slate-100 rounded text-xs text-slate-700">{ch.name}</span>
))}
</div>
</div>
)}
<div className="flex gap-2 flex-wrap">
<span className={`text-[10px] font-bold px-2 py-1 rounded ${getStatusColor(viewPostModal.status)}`}>
{getStatusLabel(viewPostModal.status)}
</span>
</div>
{viewPostModal.rejectionReason && (
<div className="bg-red-50 border border-red-200 rounded-xl p-3">
<p className="text-[10px] font-bold uppercase text-red-600 mb-1">Причина отклонения</p>
<p className="text-sm text-red-700">{viewPostModal.rejectionReason}</p>
</div>
)}
</div>
{viewPostModal.status === 'draft' && (
<div className="flex gap-2 border-t border-slate-200 pt-4">
<button
type="button"
onClick={() => {
openPostEdit(viewPostModal);
setViewPostModal(null);
}}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-xl font-bold text-sm"
>
<Pencil className="w-4 h-4 inline mr-2" /> Редактировать
</button>
<button
type="button"
onClick={() => {
backendApi.updateScheduledPost(viewPostModal.id, { status: 'pending_approval' })
.then(() => { setViewPostModal(null); loadPosts(); });
}}
className="flex-1 px-4 py-2.5 bg-primary-600 text-white rounded-xl font-bold text-sm"
>
Отправить на утверждение
</button>
<button
type="button"
onClick={() => handleDeletePost(viewPostModal.id)}
className="px-4 py-2 bg-red-100 text-red-600 rounded-xl font-bold text-sm"
>
<Trash2 className="w-4 h-4 inline mr-2" /> Удалить
</button>
</div>
)}
{viewPostModal.status === 'pending_approval' && (
<div className="space-y-3 border-t border-slate-200 pt-4">
<div>
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Редактировать текст (необязательно)</label>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm resize-none"
rows={4}
placeholder="Введите отредактированный текст поста"
/>
</div>
<div className="flex gap-2 flex-wrap">
<button
type="button"
onClick={() => handleApprovePost(viewPostModal.id)}
disabled={actionLoading}
className="flex-1 px-4 py-2.5 bg-emerald-600 text-white rounded-xl font-bold text-sm disabled:opacity-50 flex items-center justify-center gap-2"
>
<CheckCircle className="w-4 h-4" /> Одобрить
</button>
<button
type="button"
onClick={() => handleRejectPost(viewPostModal.id)}
disabled={actionLoading}
className="px-4 py-2.5 bg-red-600 text-white rounded-xl font-bold text-sm disabled:opacity-50 flex items-center gap-2"
>
<XCircle className="w-4 h-4" /> Отклонить
</button>
{editContent !== viewPostModal.content && editContent.trim() && (
<button
type="button"
onClick={() => handleSendToEdit(viewPostModal.id)}
disabled={actionLoading}
className="px-4 py-2.5 bg-blue-600 text-white rounded-xl font-bold text-sm disabled:opacity-50 flex items-center gap-2"
>
<Edit className="w-4 h-4" /> Отправить на редактирование
</button>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,122 @@
import React, { useState, useEffect } from 'react';
import { ResidentReport } from '../../types';
import { apiClient } from '../../services/apiClient';
import { ResidentReportView } from './ResidentReportView';
import { ArrowLeft, Loader2, AlertCircle } from 'lucide-react';
interface ResidentReportPageProps {
reportId: string | number;
onBack: () => void;
}
export const ResidentReportPage: React.FC<ResidentReportPageProps> = ({ reportId, onBack }) => {
const [report, setReport] = useState<ResidentReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadReport();
}, [reportId]);
const loadReport = async () => {
try {
setLoading(true);
setError(null);
const data = await apiClient.get<ResidentReport>(`/pr/reports/${reportId}`);
setReport(data);
} catch (err: any) {
console.error('Error loading report:', err);
setError(err.message || 'Не удалось загрузить отчет');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex justify-center items-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
</div>
);
}
if (error) {
return (
<div className="bg-white rounded-2xl border border-red-200 shadow-sm p-6">
<div className="flex items-center gap-2 text-red-600 mb-4">
<AlertCircle className="w-5 h-5" />
<p className="font-medium">Ошибка: {error}</p>
</div>
<button
onClick={onBack}
className="text-primary-600 hover:text-primary-700 font-medium"
>
Назад
</button>
</div>
);
}
if (!report) {
return (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 text-center">
<p className="text-slate-600">Отчет не найден</p>
<button
onClick={onBack}
className="mt-4 text-primary-600 hover:text-primary-700 font-medium"
>
Назад
</button>
</div>
);
}
const period = report.periodStart && report.periodEnd
? `${new Date(report.periodStart).toLocaleDateString('ru-RU')} - ${new Date(report.periodEnd).toLocaleDateString('ru-RU')}`
: report.month;
return (
<div className="space-y-6 animate-fade-in">
{/* Заголовок и навигация */}
<div className="flex items-center gap-4">
<button
onClick={onBack}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5 text-slate-600" />
</button>
<div className="flex-1">
<h1 className="text-2xl font-bold text-slate-800">
Отчет собственникам МКД
</h1>
<p className="text-sm text-slate-500 mt-1">
{report.address || `Отчет #${report.id}`} {period}
</p>
</div>
{report.status === 'draft' && (
<span className="px-3 py-1 bg-amber-100 text-amber-700 rounded-lg text-sm font-medium">
Черновик
</span>
)}
{report.status === 'published' && (
<span className="px-3 py-1 bg-emerald-100 text-emerald-700 rounded-lg text-sm font-medium">
Опубликован
</span>
)}
</div>
{/* Контент отчета */}
{report.content ? (
<ResidentReportView
content={report.content}
buildingAddress={report.address}
period={period}
/>
) : (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
<p className="text-slate-600">Контент отчета не загружен</p>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,410 @@
import React from 'react';
import { ResidentReportContent } from '../../types';
import { Download, Printer } from 'lucide-react';
interface ResidentReportViewProps {
content: ResidentReportContent;
buildingAddress?: string;
period?: string;
}
export const ResidentReportView: React.FC<ResidentReportViewProps> = ({
content,
buildingAddress,
period
}) => {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(amount);
};
const formatNumber = (num: number) => {
return new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(num);
};
const handleDownloadCSV = () => {
// TODO: Реализовать экспорт в CSV
alert('Экспорт в CSV будет реализован');
};
const handlePrint = () => {
window.print();
};
return (
<div className="space-y-6 animate-fade-in print:space-y-4">
{/* Кнопки действий */}
<div className="flex justify-end gap-3 print:hidden">
<button
onClick={handleDownloadCSV}
className="bg-primary-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-primary-700 transition-colors"
>
<Download className="w-4 h-4" />
Скачать CSV
</button>
<button
onClick={handlePrint}
className="bg-slate-100 text-slate-700 px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-slate-200 transition-colors"
>
<Printer className="w-4 h-4" />
Печать
</button>
</div>
{/* Параметры */}
{content.parameters && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 print:border-0 print:shadow-none">
<h3 className="text-lg font-bold text-slate-800 mb-4">Параметры:</h3>
<div className="space-y-3">
<div>
<p className="text-sm text-slate-600 mb-1">Период: {content.parameters.periodStart && content.parameters.periodEnd
? `${new Date(content.parameters.periodStart).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })} - ${new Date(content.parameters.periodEnd).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })}`
: period || 'Не указан'}
</p>
<p className="text-sm text-slate-600 mb-1">Начало периода:</p>
<p className="text-sm text-slate-600 mb-1">Конец периода:</p>
<p className="text-sm text-slate-600 mb-1">Дом: {content.parameters.building || buildingAddress || 'Не указан'}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 pt-2 border-t border-slate-200">
{content.parameters.residentialArea && (
<div>
<p className="text-sm text-slate-600 mb-1">Ж (кв.м)</p>
<p className="font-bold text-slate-800">{formatNumber(content.parameters.residentialArea)}</p>
</div>
)}
{content.parameters.nonResidentialArea && (
<div>
<p className="text-sm text-slate-600 mb-1">НЖ (кв.м) в т.ч.:</p>
<p className="font-bold text-slate-800">{formatNumber(content.parameters.nonResidentialArea)}</p>
</div>
)}
{content.parameters.parkingArea && (
<div>
<p className="text-sm text-slate-600 mb-1">Парковка</p>
<p className="font-bold text-slate-800">{formatNumber(content.parameters.parkingArea)}</p>
</div>
)}
{content.parameters.totalArea && (
<div>
<p className="text-sm text-slate-600 mb-1">Итого (кв.м)</p>
<p className="font-bold text-slate-800">{formatNumber(content.parameters.totalArea)}</p>
</div>
)}
</div>
{content.tariffs && (
<div className="grid grid-cols-2 gap-4 pt-2 border-t border-slate-200">
<div>
<p className="text-sm text-slate-600 mb-1">Тариф</p>
<p className="font-bold text-slate-800">{formatNumber(content.tariffs.tariff)}</p>
</div>
<div>
<p className="text-sm text-slate-600 mb-1">Резервный фонд</p>
<p className="font-bold text-slate-800">{formatNumber(content.tariffs.reserveFund)}</p>
</div>
</div>
)}
</div>
</div>
)}
{/* Тарифы */}
{content.tariffs && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 print:border-0 print:shadow-none">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-slate-600 mb-1">Тариф</p>
<p className="font-bold text-slate-800">{formatNumber(content.tariffs.tariff)}</p>
</div>
<div>
<p className="text-sm text-slate-600 mb-1">Резервный фонд</p>
<p className="font-bold text-slate-800">{formatNumber(content.tariffs.reserveFund)}</p>
</div>
</div>
</div>
)}
{/* Услуги */}
{content.services && content.services.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 print:border-0 print:shadow-none overflow-x-auto">
<h3 className="text-lg font-bold text-slate-800 mb-4">Услуги</h3>
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 text-sm font-bold text-slate-700">Услуга</th>
<th className="text-right py-2 px-3 text-sm font-bold text-slate-700">Долг</th>
<th className="text-right py-2 px-3 text-sm font-bold text-slate-700">Начислено</th>
<th className="text-right py-2 px-3 text-sm font-bold text-slate-700">Оплачено</th>
<th className="text-right py-2 px-3 text-sm font-bold text-slate-700">% от плана</th>
<th className="text-right py-2 px-3 text-sm font-bold text-slate-700">Порядок</th>
</tr>
</thead>
<tbody>
{content.services
.sort((a, b) => a.order - b.order)
.map((service, index) => (
<tr
key={index}
className={`border-b border-slate-100 hover:bg-slate-50 ${
service.name === 'СОДЕРЖАНИЕ ВСЕГО' ? 'bg-emerald-50 font-bold' : ''
}`}
>
<td className={`py-2 px-3 text-sm ${service.name === 'СОДЕРЖАНИЕ ВСЕГО' ? 'font-bold text-slate-900' : 'text-slate-800'}`}>
{service.name}
</td>
<td className="py-2 px-3 text-sm text-right text-slate-700">{formatCurrency(service.debt)}</td>
<td className="py-2 px-3 text-sm text-right text-slate-700">{formatCurrency(service.accrued)}</td>
<td className="py-2 px-3 text-sm text-right text-slate-700">{formatCurrency(service.paid)}</td>
<td className="py-2 px-3 text-sm text-right text-slate-700">{formatNumber(service.percentOfPlan)}</td>
<td className="py-2 px-3 text-sm text-right text-slate-500">{formatNumber(service.order)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Сальдо */}
{content.balance && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 print:border-0 print:shadow-none overflow-x-auto">
<h3 className="text-lg font-bold text-slate-800 mb-4">Сальдо</h3>
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 text-sm font-bold text-slate-700">Сальдо</th>
<th className="text-right py-2 px-3 text-sm font-bold text-slate-700">Сумма</th>
</tr>
</thead>
<tbody>
<tr className="border-b border-slate-100">
<td className="py-2 px-3 text-sm text-slate-700">Исходя из начисленных</td>
<td className={`py-2 px-3 text-sm text-right font-bold ${
(content.balance.fromAccrued || 0) < 0 ? 'text-red-600' : 'text-slate-800'
}`}>
{formatCurrency(content.balance.fromAccrued || 0)}
</td>
</tr>
<tr className="border-b border-slate-100">
<td className="py-2 px-3 text-sm text-slate-700">Исходя из поступивших средств</td>
<td className={`py-2 px-3 text-sm text-right font-bold ${
(content.balance.fromReceived || 0) < 0 ? 'text-red-600' : 'text-slate-800'
}`}>
{formatCurrency(content.balance.fromReceived || 0)}
</td>
</tr>
{content.balance.reserveFundFromAccrued !== undefined && (
<tr className="border-b border-slate-100">
<td className="py-2 px-3 text-sm text-slate-700">Сальдо Резервного фонда на начало периода исходя из Начисленых средств</td>
<td className="py-2 px-3 text-sm text-right font-bold text-slate-800">
{formatCurrency(content.balance.reserveFundFromAccrued)}
</td>
</tr>
)}
{content.balance.reserveFundFromReceived !== undefined && (
<tr>
<td className="py-2 px-3 text-sm text-slate-700">Сальдо Резервного фонда на начало периода исходя из Поступивших средств</td>
<td className="py-2 px-3 text-sm text-right font-bold text-slate-800">
{formatCurrency(content.balance.reserveFundFromReceived)}
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Статьи затрат */}
{content.expenseItems && content.expenseItems.length > 0 && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 print:border-0 print:shadow-none overflow-x-auto">
<h3 className="text-lg font-bold text-slate-800 mb-4">Статьи затрат</h3>
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-3 text-sm font-bold text-slate-700"> п/п</th>
<th className="text-left py-2 px-3 text-sm font-bold text-slate-700">Статья затрат</th>
<th className="text-right py-2 px-3 text-sm font-bold text-slate-700">В месяц</th>
<th className="text-right py-2 px-3 text-sm font-bold text-slate-700">Сумма</th>
<th className="text-right py-2 px-3 text-sm font-bold text-slate-700">руб/кв.м в месяц</th>
</tr>
</thead>
<tbody>
{content.expenseItems.map((item, index) => (
<React.Fragment key={index}>
<tr className="border-b border-slate-100">
<td className="py-2 px-3 text-sm text-slate-800 font-medium">{item.number}</td>
<td className="py-2 px-3 text-sm text-slate-800 font-bold">{item.name}</td>
<td className="py-2 px-3 text-sm text-right text-slate-700">{formatCurrency(item.perMonth)}</td>
<td className="py-2 px-3 text-sm text-right text-slate-700 font-bold">{formatCurrency(item.total)}</td>
<td className="py-2 px-3 text-sm text-right text-slate-700">{formatNumber(item.perSquareMeter)}</td>
</tr>
{item.children && item.children.map((child, childIndex) => (
<React.Fragment key={`${index}-${childIndex}`}>
<tr className="border-b border-slate-50 bg-slate-50/50">
<td className="py-2 px-3 text-sm text-slate-700 pl-8">{child.number}</td>
<td className="py-2 px-3 text-sm text-slate-700">{child.name}</td>
<td className="py-2 px-3 text-sm text-right text-slate-600">{formatCurrency(child.perMonth)}</td>
<td className="py-2 px-3 text-sm text-right text-slate-600">{formatCurrency(child.total)}</td>
<td className="py-2 px-3 text-sm text-right text-slate-600">{formatNumber(child.perSquareMeter)}</td>
</tr>
{child.children && child.children.map((grandchild, grandchildIndex) => (
<tr key={`${index}-${childIndex}-${grandchildIndex}`} className="border-b border-slate-50 bg-slate-50/30">
<td className="py-2 px-3 text-sm text-slate-600 pl-12">{grandchild.number}</td>
<td className="py-2 px-3 text-sm text-slate-600">{grandchild.name}</td>
<td className="py-2 px-3 text-sm text-right text-slate-500">{formatCurrency(grandchild.perMonth)}</td>
<td className="py-2 px-3 text-sm text-right text-slate-500">{formatCurrency(grandchild.total)}</td>
<td className="py-2 px-3 text-sm text-right text-slate-500">{formatNumber(grandchild.perSquareMeter)}</td>
</tr>
))}
</React.Fragment>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
)}
{/* Итоги */}
{content.totals && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 print:border-0 print:shadow-none overflow-x-auto">
<h3 className="text-lg font-bold text-slate-800 mb-4">Итоги</h3>
<table className="w-full border-collapse">
<tbody>
<tr className="border-b border-slate-200">
<td className="py-2 px-3 text-sm font-bold text-slate-800">Итого</td>
<td className="py-2 px-3 text-sm text-right text-slate-700">{formatCurrency(content.totals.totalExpenses / (content.parameters?.totalArea ? (new Date(content.parameters.periodEnd).getTime() - new Date(content.parameters.periodStart).getTime()) / (1000 * 60 * 60 * 24 * 30) : 1))}</td>
<td className="py-2 px-3 text-sm text-right font-bold text-slate-800">{formatCurrency(content.totals.totalExpenses)}</td>
<td className="py-2 px-3 text-sm text-right text-slate-700">{formatNumber(content.parameters?.totalArea ? (content.totals.totalExpenses / (content.parameters.totalArea * ((new Date(content.parameters.periodEnd).getTime() - new Date(content.parameters.periodStart).getTime()) / (1000 * 60 * 60 * 24 * 30)))) : 0)}</td>
</tr>
<tr>
<td colSpan={4} className="py-2"></td>
</tr>
<tr>
<td className="py-2 px-3 text-sm text-slate-700">НДС</td>
<td colSpan={2} className="py-2 px-3 text-sm text-right text-slate-700">{formatCurrency(content.totals.vat)}</td>
<td></td>
</tr>
{content.totals.recalculation > 0 && (
<tr>
<td className="py-2 px-3 text-sm text-slate-700">Перерасчет (механизированная уборка, резервный фонд)</td>
<td colSpan={2} className="py-2 px-3 text-sm text-right text-slate-700">{formatCurrency(content.totals.recalculation)}</td>
<td></td>
</tr>
)}
<tr>
<td className="py-2 px-3 text-sm font-medium text-slate-700">Итого расходов с учетом перерасчета (без НДС)</td>
<td colSpan={2} className="py-2 px-3 text-sm text-right font-bold text-slate-800">{formatCurrency(content.totals.totalWithRecalculation)}</td>
<td></td>
</tr>
<tr>
<td className="py-2 px-3 text-sm font-medium text-slate-700">Итого расходов с учетом перерасчета</td>
<td className="py-2 px-3 text-sm text-right text-slate-700">{formatCurrency(content.totals.totalWithRecalculationWithVAT / (content.parameters?.totalArea ? (new Date(content.parameters.periodEnd).getTime() - new Date(content.parameters.periodStart).getTime()) / (1000 * 60 * 60 * 24 * 30) : 1))}</td>
<td className="py-2 px-3 text-sm text-right font-bold text-slate-800">{formatCurrency(content.totals.totalWithRecalculationWithVAT)}</td>
<td></td>
</tr>
<tr>
<td className="py-2 px-3 text-sm text-slate-700">Возврат долга</td>
<td colSpan={2} className="py-2 px-3 text-sm text-right text-slate-700">{content.totals.debtReturn > 0 ? formatCurrency(content.totals.debtReturn) : ''}</td>
<td></td>
</tr>
<tr>
<td className="py-2 px-3 text-sm font-bold text-slate-800">Итого тариф</td>
<td className="py-2 px-3 text-sm text-right text-slate-700">{formatCurrency(content.totals.totalTariff / (content.parameters?.totalArea ? (new Date(content.parameters.periodEnd).getTime() - new Date(content.parameters.periodStart).getTime()) / (1000 * 60 * 60 * 24 * 30) : 1))}</td>
<td className="py-2 px-3 text-sm text-right font-bold text-slate-800">{formatCurrency(content.totals.totalTariff)}</td>
<td className="py-2 px-3 text-sm text-right text-slate-700">{formatNumber(content.parameters?.totalArea ? (content.totals.totalTariff / (content.parameters.totalArea * ((new Date(content.parameters.periodEnd).getTime() - new Date(content.parameters.periodStart).getTime()) / (1000 * 60 * 60 * 24 * 30)))) : 0)}</td>
</tr>
{content.totals.otherIncome > 0 && (() => {
const periodMonths = content.parameters?.totalArea
? (new Date(content.parameters.periodEnd).getTime() - new Date(content.parameters.periodStart).getTime()) / (1000 * 60 * 60 * 24 * 30)
: 1;
const otherIncomePerM2 = content.parameters?.totalArea
? content.totals.otherIncome / (content.parameters.totalArea * periodMonths)
: 0;
return (
<tr>
<td className="py-2 px-3 text-sm text-slate-700">Прочие доходы</td>
<td className="py-2 px-3 text-sm text-right text-slate-700">{formatCurrency(content.totals.otherIncome / periodMonths)}</td>
<td className="py-2 px-3 text-sm text-right text-slate-700">{formatCurrency(content.totals.otherIncome)}</td>
<td className="py-2 px-3 text-sm text-right text-slate-700">{formatNumber(otherIncomePerM2)}</td>
</tr>
);
})()}
</tbody>
</table>
</div>
)}
{/* Финансовые результаты */}
{content.financialResults && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 print:border-0 print:shadow-none overflow-x-auto">
<h3 className="text-lg font-bold text-slate-800 mb-4">Финансовый результат</h3>
<table className="w-full border-collapse">
<tbody>
<tr>
<td colSpan={4} className="py-2 px-3 text-sm font-bold text-slate-800">Финансовый результат по содержанию</td>
</tr>
<tr>
<td colSpan={2} className="py-2 px-3 text-sm text-slate-700">исходя из начисленых</td>
<td className="py-2 px-3 text-sm text-right font-bold text-slate-800">{formatCurrency(content.financialResults.maintenanceFromAccrued || 0)}</td>
<td className={`py-2 px-3 text-sm text-right font-bold ${
(content.financialResults.maintenanceFromAccrued || 0) < 0 ? 'text-red-600' : 'text-slate-800'
}`}>
{formatNumber(content.parameters?.totalArea ? ((content.financialResults.maintenanceFromAccrued || 0) / content.parameters.totalArea) : 0)}
</td>
</tr>
<tr>
<td colSpan={2} className="py-2 px-3 text-sm text-slate-700">исходя из поступивших средств</td>
<td className={`py-2 px-3 text-sm text-right font-bold ${
(content.financialResults.maintenanceFromReceived || 0) < 0 ? 'text-red-600' : 'text-slate-800'
}`}>
{formatCurrency(content.financialResults.maintenanceFromReceived || 0)}
</td>
<td className={`py-2 px-3 text-sm text-right font-bold ${
(content.financialResults.maintenanceFromReceived || 0) < 0 ? 'text-red-600' : 'text-slate-800'
}`}>
{formatNumber(content.parameters?.totalArea ? ((content.financialResults.maintenanceFromReceived || 0) / content.parameters.totalArea) : 0)}
</td>
</tr>
<tr>
<td colSpan={4} className="py-2"></td>
</tr>
<tr>
<td colSpan={4} className="py-2 px-3 text-sm font-bold text-slate-800">Финансовый результат по резервному фонду</td>
</tr>
<tr>
<td colSpan={2} className="py-2 px-3 text-sm text-slate-700">исходя из начисленых</td>
<td className="py-2 px-3 text-sm text-right font-bold text-slate-800">{formatCurrency(content.financialResults.reserveFundFromAccrued || 0)}</td>
<td className="py-2 px-3 text-sm text-right font-bold text-slate-800">
{formatNumber(content.parameters?.totalArea ? ((content.financialResults.reserveFundFromAccrued || 0) / content.parameters.totalArea) : 0)}
</td>
</tr>
<tr>
<td colSpan={2} className="py-2 px-3 text-sm text-slate-700">исходя из поступивших средств</td>
<td className="py-2 px-3 text-sm text-right font-bold text-slate-800">{formatCurrency(content.financialResults.reserveFundFromReceived || 0)}</td>
<td className="py-2 px-3 text-sm text-right font-bold text-slate-800">
{formatNumber(content.parameters?.totalArea ? ((content.financialResults.reserveFundFromReceived || 0) / content.parameters.totalArea) : 0)}
</td>
</tr>
</tbody>
</table>
</div>
)}
{/* Подпись директора */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 print:border-0 print:shadow-none">
<div className="flex justify-end">
<div className="text-right">
<p className="text-sm font-bold text-slate-800 mb-1">Директор</p>
<p className="text-sm text-slate-600">_________________</p>
</div>
</div>
</div>
</div>
);
};

699
components/pr/ResidentReports.tsx Executable file
View File

@@ -0,0 +1,699 @@
import React, { useState, useEffect } from 'react';
import { ResidentReport, Building, WorkPhoto } from '../../types';
import { apiClient } from '../../services/apiClient';
import { FileText, Sparkles, Send, Download, Eye, CheckCircle2, History, Bot, X, Loader2, Calendar, Link2, Key } from 'lucide-react';
import { BuildingReportPage } from './BuildingReportPage';
export const ResidentReports: React.FC = () => {
const [reports, setReports] = useState<ResidentReport[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedReportId, setSelectedReportId] = useState<string | number | null>(null);
const [showAccessKeyModal, setShowAccessKeyModal] = useState(false);
useEffect(() => {
loadReports();
}, []);
const loadReports = async () => {
try {
setIsLoading(true);
const data = await apiClient.get<ResidentReport[]>('/pr/reports');
setReports(data);
} catch (err) {
console.error('Error loading reports:', err);
setReports([]);
} finally {
setIsLoading(false);
}
};
const handlePublish = async (reportId: string | number) => {
try {
// При публикации отчет обновляется актуальными данными
await apiClient.post(`/pr/reports/${reportId}/publish`);
await loadReports();
alert('Отчет обновлен актуальными данными и опубликован');
} catch (err: any) {
console.error('Error publishing report:', err);
alert(`Ошибка публикации отчета: ${err.message || 'Неизвестная ошибка'}`);
}
};
// Если выбран отчет для просмотра, показываем страницу отчета
if (selectedReportId) {
// Для демо используем BuildingReportPage
if (selectedReportId === 'demo') {
return (
<BuildingReportPage
buildingAddress="Кавказская, 12"
month="Январь 2025"
onBack={() => setSelectedReportId(null)}
mode="portal"
reportId="demo"
/>
);
}
// Для реальных отчетов используем BuildingReportPage с данными из БД
const report = reports.find(r => r.id === selectedReportId);
if (report) {
// Определяем текущий месяц из отчета или используем текущий месяц
const now = new Date();
const currentMonthName = now.toLocaleDateString('ru-RU', { month: 'long' });
const currentYear = now.getFullYear();
const initialMonth = report.month || `${currentMonthName} ${currentYear}`;
return (
<BuildingReportPage
buildingId={report.buildingId}
buildingAddress={report.address || `Отчет #${report.id}`}
month={initialMonth}
onBack={() => setSelectedReportId(null)}
mode="portal"
reportId={report.buildingId} // Используем buildingId для загрузки данных по месяцам
/>
);
}
// Fallback, если отчет не найден
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<p className="text-slate-600 mb-4">Отчет не найден</p>
<button
onClick={() => setSelectedReportId(null)}
className="px-4 py-2 bg-primary-600 text-white rounded-xl"
>
Вернуться к списку
</button>
</div>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h3 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em]">Реестр ежемесячных отчетов</h3>
<p className="text-xs text-slate-400 mt-1">Отчеты создаются автоматически 1 числа каждого месяца</p>
</div>
</div>
{/* List of Reports */}
{isLoading ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>
) : (
<div className="space-y-4">
{reports.map(report => (
<div key={report.id} className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-sm flex flex-col md:flex-row justify-between gap-6 hover:border-primary-200 transition-all group">
<div className="flex items-center gap-4 flex-1">
<div className={`p-4 rounded-3xl ${report.status === 'published' ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600'}`}>
<FileText className="w-8 h-8"/>
</div>
<div>
<h4 className="font-black text-slate-800 text-base leading-tight group-hover:text-primary-600 transition-colors">
{report.address || `Отчет #${report.id}`}
</h4>
<p className="text-[10px] text-slate-400 font-bold uppercase mt-1">
{report.month} {report.status === 'published' ? 'Опубликован' : 'Черновик'}
</p>
{(report.liveStats || (report.content && typeof report.content === 'object')) && (
<div className="flex gap-4 mt-4">
<ReportStat
label="Заявок"
value={report.liveStats?.applicationsTotal ?? report.content?.applications?.total ?? 0}
/>
<ReportStat
label="NPS"
value={report.liveStats?.npsScore ?? (report.content as any)?.nps?.score ?? report.content?.statistics?.nps ?? 0}
/>
<ReportStat
label="Собрано"
value={(() => {
const collected = report.liveStats?.fundsCollected ?? (report.content as any)?.finances?.collected ?? report.content?.finances?.income ?? 0;
return `${(collected / 1000000).toFixed(1)}M ₽`;
})()}
/>
</div>
)}
</div>
</div>
<div className="flex items-center justify-between md:justify-end gap-2 border-t md:border-t-0 border-slate-50 pt-4 md:pt-0">
<button
onClick={() => setSelectedReportId(report.id)}
className="p-3 bg-slate-50 text-slate-500 rounded-2xl hover:bg-slate-100"
title="Открыть отчет"
>
<Eye className="w-5 h-5"/>
</button>
<button className="p-3 bg-slate-50 text-slate-500 rounded-2xl hover:bg-slate-100" title="Скачать PDF">
<Download className="w-5 h-5"/>
</button>
{report.status === 'draft' ? (
<button
onClick={() => handlePublish(report.id)}
className="bg-primary-600 text-white px-5 py-3 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-lg flex items-center gap-2 ml-2 active:scale-95 transition-all"
title="Обновить данные и опубликовать отчет"
>
<Send className="w-4 h-4"/> Опубликовать
</button>
) : (
<div className="bg-emerald-50 text-emerald-600 px-5 py-3 rounded-2xl text-[10px] font-black uppercase flex items-center gap-2 border border-emerald-100 ml-2">
<CheckCircle2 className="w-4 h-4"/> Опубликован
</div>
)}
</div>
</div>
))}
</div>
)}
{/* Модальное окно с ключом доступа */}
{showAccessKeyModal && (
<AccessKeyModal
onClose={() => setShowAccessKeyModal(false)}
reportId="demo"
/>
)}
</div>
);
};
const ReportStat = ({ label, value }: { label: string; value: string | number }) => (
<div className="text-left">
<p className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{label}</p>
<p className="text-xs font-black text-slate-700">{value}</p>
</div>
);
interface ReportCreateFormProps {
onClose: () => void;
onSuccess: () => void;
selectedBuilding: Building | null;
onBuildingSelect: (building: Building | null) => void;
}
const ReportCreateForm: React.FC<ReportCreateFormProps> = ({ onClose, onSuccess, selectedBuilding, onBuildingSelect }) => {
const [buildings, setBuildings] = useState<Building[]>([]);
const [formData, setFormData] = useState({
building_id: '',
month: '',
period_start: '',
period_end: '',
createForAll: false, // Создать для всех домов
selectedBuildings: [] as string[] // Выбранные дома для массового создания
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [reportData, setReportData] = useState<any>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [creationProgress, setCreationProgress] = useState<{
total: number;
completed: number;
current: string;
} | null>(null);
useEffect(() => {
loadBuildings();
}, []);
useEffect(() => {
if (formData.building_id && formData.period_start && formData.period_end) {
loadReportData();
}
}, [formData.building_id, formData.period_start, formData.period_end]);
const loadBuildings = async () => {
try {
const data = await apiClient.get<Building[]>('/buildings');
setBuildings(data);
} catch (err) {
console.error('Error loading buildings:', err);
}
};
const loadReportData = async () => {
try {
setIsGenerating(true);
// Загружаем данные для отчета
const building = buildings.find(b => b.id === formData.building_id);
if (!building) return;
// Заявки
const applications = await apiClient.get(`/applications`).catch(() => []);
// Финансовые данные
const financialData = await apiClient.get(`/finance/buildings/${formData.building_id}/summary`).catch(() => null);
// Фото отчеты
const workPhotos = await apiClient.get<WorkPhoto[]>(`/pr/work-photos?building_id=${formData.building_id}`).catch(() => []);
setReportData({
building,
applications: Array.isArray(applications) ? applications : [],
financialData,
workPhotos: Array.isArray(workPhotos) ? workPhotos : []
});
} catch (err) {
console.error('Error loading report data:', err);
} finally {
setIsGenerating(false);
}
};
const handleGenerate = async () => {
if (!formData.period_start || !formData.period_end) {
alert('Заполните период отчета');
return;
}
if (!formData.createForAll && !formData.building_id) {
alert('Выберите дом или включите создание для всех домов');
return;
}
try {
setIsGenerating(true);
setIsSubmitting(true);
if (formData.createForAll) {
// Массовое создание для всех домов
const targetBuildings = formData.selectedBuildings.length > 0
? formData.selectedBuildings
: buildings.map(b => b.id);
setCreationProgress({ total: targetBuildings.length, completed: 0, current: 'Начало создания...' });
const result = await apiClient.post<{
success: boolean;
reportsCreated: number;
reports: Array<{ buildingId: string; reportId: number; updated: boolean }>;
}>('/pr/reports/bulk-create', {
month: formData.month,
period_start: formData.period_start,
period_end: formData.period_end,
building_ids: targetBuildings
});
setCreationProgress({
total: targetBuildings.length,
completed: result.reportsCreated,
current: 'Завершено'
});
setTimeout(() => {
setCreationProgress(null);
alert(`Успешно создано отчетов: ${result.reportsCreated} из ${targetBuildings.length}`);
onSuccess();
}, 1000);
} else {
// Создание для одного дома
const report = await apiClient.post<ResidentReport>('/pr/reports', {
building_id: formData.building_id,
month: formData.month,
period_start: formData.period_start,
period_end: formData.period_end
});
// Генерируем контент
await apiClient.post(`/pr/reports/${report.id}/generate`);
onSuccess();
}
} catch (err: any) {
console.error('Error creating report:', err);
alert(`Ошибка создания отчета: ${err.message || 'Неизвестная ошибка'}`);
} finally {
setIsGenerating(false);
setIsSubmitting(false);
setCreationProgress(null);
}
};
const selectedBuildingData = buildings.find(b => b.id === formData.building_id);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 overflow-y-auto">
<div className="bg-white rounded-3xl p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto my-8">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-slate-800">Создать отчет жителям</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-xl">
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<div className="space-y-6">
{/* Опция создания для всех домов */}
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.createForAll}
onChange={e => setFormData({
...formData,
createForAll: e.target.checked,
building_id: e.target.checked ? '' : formData.building_id
})}
className="w-5 h-5 text-indigo-600 rounded focus:ring-indigo-500"
/>
<div>
<span className="font-black text-slate-800">Создать отчеты для всех домов</span>
<p className="text-xs text-slate-600 mt-1">
Автоматически создаст отчеты для всех домов за указанный период
</p>
</div>
</label>
</div>
{/* Форма выбора дома и периода */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{!formData.createForAll ? (
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Дом
</label>
<select
value={formData.building_id}
onChange={e => setFormData({ ...formData, building_id: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required={!formData.createForAll}
>
<option value="">Выберите дом</option>
{buildings.map(b => (
<option key={b.id} value={b.id}>
{b.passport?.address || b.id}
</option>
))}
</select>
</div>
) : (
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Выбрать дома (оставьте пустым для всех)
</label>
<div className="max-h-48 overflow-y-auto border border-slate-200 rounded-xl p-3 space-y-2">
{buildings.map(b => (
<label key={b.id} className="flex items-center gap-2 cursor-pointer hover:bg-slate-50 p-2 rounded">
<input
type="checkbox"
checked={formData.selectedBuildings.includes(b.id)}
onChange={e => {
if (e.target.checked) {
setFormData({
...formData,
selectedBuildings: [...formData.selectedBuildings, b.id]
});
} else {
setFormData({
...formData,
selectedBuildings: formData.selectedBuildings.filter(id => id !== b.id)
});
}
}}
className="w-4 h-4 text-indigo-600 rounded"
/>
<span className="text-sm text-slate-700">{b.passport?.address || b.id}</span>
</label>
))}
</div>
<p className="text-xs text-slate-500 mt-2">
Выбрано: {formData.selectedBuildings.length || 'Все'} домов
</p>
</div>
)}
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Месяц (например: Май 2024)
</label>
<input
type="text"
value={formData.month}
onChange={e => setFormData({ ...formData, month: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
placeholder="Май 2024"
required
/>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Период начала
</label>
<input
type="date"
value={formData.period_start}
onChange={e => setFormData({ ...formData, period_start: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
/>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Период окончания
</label>
<input
type="date"
value={formData.period_end}
onChange={e => setFormData({ ...formData, period_end: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
/>
</div>
</div>
{/* Прогресс создания */}
{creationProgress && (
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="font-black text-slate-800">Создание отчетов</h4>
<span className="text-sm font-bold text-indigo-600">
{creationProgress.completed} / {creationProgress.total}
</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-3 mb-2">
<div
className="bg-indigo-600 h-3 rounded-full transition-all duration-300"
style={{ width: `${(creationProgress.completed / creationProgress.total) * 100}%` }}
/>
</div>
{creationProgress.current && (
<p className="text-xs text-slate-600 mt-2">
Обрабатывается: {creationProgress.current}
</p>
)}
</div>
)}
{/* Данные отчета */}
{isGenerating && !creationProgress && (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
</div>
)}
{reportData && !isGenerating && (
<div className="space-y-4 border-t pt-6">
<h4 className="font-black text-slate-800 text-lg">Данные для отчета</h4>
{/* Информация о доме */}
{reportData.building && (
<div className="bg-slate-50 p-4 rounded-xl">
<h5 className="font-bold text-slate-800 mb-2">Информация о доме</h5>
<p className="text-sm text-slate-600">
{reportData.building.passport?.address || 'Адрес не указан'}
</p>
{reportData.building.passport?.apartmentsCount && (
<p className="text-xs text-slate-500 mt-1">
Квартир: {reportData.building.passport.apartmentsCount}
</p>
)}
</div>
)}
{/* Заявки */}
{reportData.applications && (
<div className="bg-slate-50 p-4 rounded-xl">
<h5 className="font-bold text-slate-800 mb-2">Заявки за период</h5>
<p className="text-sm text-slate-600">
Всего: {reportData.applications.length}
</p>
</div>
)}
{/* Финансы */}
{reportData.financialData && (
<div className="bg-slate-50 p-4 rounded-xl">
<h5 className="font-bold text-slate-800 mb-2">Финансы</h5>
<p className="text-sm text-slate-600">
Доходы: {reportData.financialData.totalIncome || 0}
</p>
<p className="text-sm text-slate-600">
Расходы: {reportData.financialData.totalExpenses || 0}
</p>
</div>
)}
{/* Фото отчеты */}
{reportData.workPhotos && reportData.workPhotos.length > 0 && (
<div className="bg-slate-50 p-4 rounded-xl">
<h5 className="font-bold text-slate-800 mb-2">Фото отчеты</h5>
<p className="text-sm text-slate-600">
Работ: {reportData.workPhotos.length}
</p>
</div>
)}
</div>
)}
{/* Кнопки */}
<div className="flex gap-3 pt-4 border-t">
<button
onClick={handleGenerate}
disabled={isSubmitting || isGenerating || (!formData.createForAll && !formData.building_id) || !formData.period_start || !formData.period_end}
className="flex-1 px-6 py-3 bg-primary-600 text-white rounded-xl text-xs font-black uppercase tracking-widest disabled:opacity-50"
>
{isSubmitting || isGenerating
? (formData.createForAll ? `Создание отчетов...` : 'Создание...')
: (formData.createForAll ? `Создать отчеты для всех домов` : 'Создать отчет')}
</button>
<button
type="button"
onClick={onClose}
className="px-6 py-3 bg-slate-100 text-slate-600 rounded-xl text-xs font-black uppercase tracking-widest"
>
Отмена
</button>
</div>
</div>
</div>
</div>
);
};
// Модальное окно с ключом доступа
interface AccessKeyModalProps {
onClose: () => void;
reportId: string | number;
}
const AccessKeyModal: React.FC<AccessKeyModalProps> = ({ onClose, reportId }) => {
const accessKey = `mkd-${String(reportId)}-key`; // Уникальный ключ на каждый отчёт (демо)
const link = `${window.location.origin}/reports/${reportId}?mode=published&key=${accessKey}`;
const [copied, setCopied] = useState(false);
const [showKey, setShowKey] = useState(false);
const handleCopyLink = () => {
navigator.clipboard.writeText(link);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleCopyKey = () => {
navigator.clipboard.writeText(accessKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-3xl p-6 max-w-lg w-full shadow-2xl">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-100 rounded-xl">
<Key className="w-6 h-6 text-indigo-600" />
</div>
<h3 className="text-xl font-black text-slate-800">Ключ доступа к отчету</h3>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-xl transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Ключ доступа
</label>
<div className="flex gap-2">
<div className="flex-1 relative">
<input
type={showKey ? "text" : "password"}
value={accessKey}
readOnly
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500 pr-12"
/>
<button
onClick={() => setShowKey(!showKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
type="button"
>
{showKey ? <X className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<button
onClick={handleCopyKey}
className="px-4 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
{copied ? '✓' : 'Копировать'}
</button>
</div>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Ссылка на опубликованную версию
</label>
<div className="flex gap-2">
<input
type="text"
value={link}
readOnly
className="flex-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<button
onClick={handleCopyLink}
className="px-4 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
{copied ? '✓' : 'Копировать'}
</button>
</div>
</div>
<div className="p-4 bg-amber-50 border border-amber-200 rounded-xl">
<p className="text-xs text-amber-800 leading-relaxed">
<strong className="font-black">Важно:</strong> Сохраните ключ доступа в безопасном месте.
Он потребуется для просмотра опубликованной версии отчета.
</p>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={() => {
window.open(link, '_blank');
onClose();
}}
className="flex-1 px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-sm font-bold transition-colors"
>
Открыть опубликованную версию
</button>
<button
onClick={onClose}
className="px-6 py-3 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-sm font-bold transition-colors"
>
Закрыть
</button>
</div>
</div>
</div>
</div>
);
};

1015
components/pr/SMMManager.tsx Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,316 @@
import React, { useState, useEffect } from 'react';
import { WorkPhoto } from '../../types';
import { apiClient } from '../../services/apiClient';
import { Plus, Loader2, Image as ImageIcon, Calendar, Building2, FileText, X } from 'lucide-react';
const UPLOADS_BASE = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api').replace(/\/api\/?$/, '') || 'http://localhost:4000';
export const WorkPhotosDirectory: React.FC = () => {
const [photos, setPhotos] = useState<WorkPhoto[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const loadPhotos = async () => {
try {
setIsLoading(true);
setLoadError(null);
const data = await apiClient.get<WorkPhoto[]>('/pr/work-photos');
setPhotos(Array.isArray(data) ? data : []);
} catch (err: any) {
console.error('Error loading work photos:', err);
setPhotos([]);
setLoadError(err?.message || 'Не удалось загрузить список. Проверьте подключение к серверу.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadPhotos();
}, []);
const handleDelete = async (id: number) => {
if (!confirm('Удалить запись из справочника?')) return;
try {
await apiClient.delete(`/pr/work-photos/${id}`);
await loadPhotos();
} catch (err) {
console.error('Error deleting work photo:', err);
alert('Ошибка удаления');
}
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="font-black text-slate-800 text-sm uppercase tracking-wider flex items-center gap-2">
<FileText className="w-4 h-4 text-primary-500" />
Справочник фотоотчётов (до / после)
</h3>
<button
onClick={() => setShowCreateForm(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-xl text-xs font-black uppercase tracking-wider flex items-center gap-2"
>
<Plus className="w-4 h-4" /> Добавить
</button>
</div>
{showCreateForm && (
<WorkPhotoCreateForm
onClose={() => setShowCreateForm(false)}
onSuccess={() => {
setShowCreateForm(false);
loadPhotos();
}}
/>
)}
{loadError && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 flex items-center justify-between gap-4">
<p className="text-sm text-amber-800">{loadError}</p>
<button
type="button"
onClick={() => loadPhotos()}
className="px-4 py-2 bg-amber-600 text-white rounded-xl text-xs font-bold hover:bg-amber-700"
>
Повторить
</button>
</div>
)}
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-slate-400" />
</div>
) : photos.length === 0 && !loadError ? (
<div className="text-center py-12 text-slate-400 bg-slate-50 rounded-2xl border border-slate-100">
<ImageIcon className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm font-bold">Записей пока нет</p>
<p className="text-xs mt-1">Добавьте фотоотчёт с полями: фото до, фото после, описание работы, дата работы, дом.</p>
</div>
) : photos.length === 0 ? null : (
<div className="bg-white rounded-2xl border border-slate-200 overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50/80">
<th className="px-4 py-3 font-black text-slate-600 uppercase tracking-wider whitespace-nowrap">Фото до</th>
<th className="px-4 py-3 font-black text-slate-600 uppercase tracking-wider whitespace-nowrap">Фото после</th>
<th className="px-4 py-3 font-black text-slate-600 uppercase tracking-wider whitespace-nowrap">Описание работы</th>
<th className="px-4 py-3 font-black text-slate-600 uppercase tracking-wider whitespace-nowrap">Дата работы</th>
<th className="px-4 py-3 font-black text-slate-600 uppercase tracking-wider whitespace-nowrap">Дом</th>
<th className="px-4 py-3 font-black text-slate-600 uppercase tracking-wider w-20"> </th>
</tr>
</thead>
<tbody>
{photos.map((photo) => (
<tr key={photo.id} className="border-b border-slate-100 hover:bg-slate-50/50 transition-colors">
<td className="px-4 py-3 align-top">
{(photo as any).photoBeforeUrl || photo.photoBeforeUrl ? (
<img
src={UPLOADS_BASE + ((photo as any).photoBeforeUrl || photo.photoBeforeUrl)}
alt="До"
className="w-24 h-20 object-cover rounded-lg border border-slate-200"
/>
) : (
<div className="w-24 h-20 rounded-lg border border-dashed border-slate-200 flex items-center justify-center bg-slate-50">
<ImageIcon className="w-6 h-6 text-slate-300" />
</div>
)}
</td>
<td className="px-4 py-3 align-top">
{(photo as any).photoAfterUrl || photo.photoAfterUrl ? (
<img
src={UPLOADS_BASE + ((photo as any).photoAfterUrl || photo.photoAfterUrl)}
alt="После"
className="w-24 h-20 object-cover rounded-lg border border-slate-200"
/>
) : (
<div className="w-24 h-20 rounded-lg border border-dashed border-slate-200 flex items-center justify-center bg-slate-50">
<ImageIcon className="w-6 h-6 text-slate-300" />
</div>
)}
</td>
<td className="px-4 py-3 max-w-xs">
<span className="font-bold text-slate-800">{(photo as any).workName || photo.workName}</span>
{((photo as any).description || photo.description) && (
<p className="text-slate-600 text-xs mt-1">{(photo as any).description || photo.description}</p>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-slate-700">
<span className="flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-slate-400" />
{new Date((photo as any).workDate || photo.workDate).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })}
</span>
</td>
<td className="px-4 py-3">
<span className="flex items-center gap-1 text-slate-700">
<Building2 className="w-3.5 h-3.5 text-slate-400" />
{(photo as any).address || photo.address || (photo as any).buildingId || photo.buildingId || '—'}
</span>
</td>
<td className="px-4 py-3">
<button
onClick={() => handleDelete(photo.id)}
className="p-1.5 text-slate-400 hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors"
title="Удалить"
>
<X className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
};
interface WorkPhotoCreateFormProps {
onClose: () => void;
onSuccess: () => void;
}
const WorkPhotoCreateForm: React.FC<WorkPhotoCreateFormProps> = ({ onClose, onSuccess }) => {
const [formData, setFormData] = useState({
building_id: '',
work_name: '',
work_date: new Date().toISOString().split('T')[0],
description: '',
});
const [photoBefore, setPhotoBefore] = useState<File | null>(null);
const [photoAfter, setPhotoAfter] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [buildings, setBuildings] = useState<{ id: string; address?: string }[]>([]);
useEffect(() => {
apiClient.get<{ id: string; passport?: { address?: string } }[]>('/buildings').then((list) => {
setBuildings((list || []).map((b) => ({ id: b.id, address: b.passport?.address })));
}).catch(() => setBuildings([]));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.building_id || !formData.work_name || !formData.work_date) {
alert('Заполните: дом, описание работы, дата работы');
return;
}
if (!photoBefore || !photoAfter) {
alert('Загрузите оба фото: «До» и «После»');
return;
}
try {
setIsSubmitting(true);
const fd = new FormData();
fd.append('building_id', formData.building_id);
fd.append('work_name', formData.work_name);
fd.append('work_date', formData.work_date);
if (formData.description) fd.append('description', formData.description);
fd.append('photo_before', photoBefore);
fd.append('photo_after', photoAfter);
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api';
const res = await fetch(`${API_BASE}/pr/work-photos`, { method: 'POST', body: fd });
if (!res.ok) throw new Error('Ошибка создания');
onSuccess();
} catch (err) {
console.error(err);
alert('Не удалось создать запись');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-xl">
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg font-black text-slate-800">Новая запись в справочнике</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-xl">
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-600 uppercase tracking-wider mb-2">Дом *</label>
<select
value={formData.building_id}
onChange={(e) => setFormData({ ...formData, building_id: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
>
<option value="">Выберите дом</option>
{buildings.map((b) => (
<option key={b.id} value={b.id}>{b.address || b.id}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 uppercase tracking-wider mb-2">Описание работы *</label>
<input
type="text"
value={formData.work_name}
onChange={(e) => setFormData({ ...formData, work_name: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
placeholder="Например: Ремонт подъезда"
required
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 uppercase tracking-wider mb-2">Дата работы *</label>
<input
type="date"
value={formData.work_date}
onChange={(e) => setFormData({ ...formData, work_date: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 uppercase tracking-wider mb-2">Доп. описание</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm resize-none"
rows={2}
placeholder="Что сделано..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-600 uppercase tracking-wider mb-2">Фото «До» *</label>
<input
type="file"
accept="image/*"
onChange={(e) => setPhotoBefore(e.target.files?.[0] || null)}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 uppercase tracking-wider mb-2">Фото «После» *</label>
<input
type="file"
accept="image/*"
onChange={(e) => setPhotoAfter(e.target.files?.[0] || null)}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
/>
</div>
</div>
<div className="flex gap-3 pt-4">
<button type="submit" disabled={isSubmitting} className="flex-1 px-6 py-3 bg-primary-600 text-white rounded-xl text-xs font-black uppercase disabled:opacity-50">
{isSubmitting ? 'Создание...' : 'Создать'}
</button>
<button type="button" onClick={onClose} className="px-6 py-3 border border-slate-200 rounded-xl text-xs font-bold text-slate-600">
Отмена
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,358 @@
import React, { useState, useEffect } from 'react';
import { WorkPhoto } from '../../types';
import { apiClient } from '../../services/apiClient';
import { Plus, X, Image as ImageIcon, Loader2, Calendar, Building2, FileText } from 'lucide-react';
interface WorkPhotosManagerProps {
buildingId?: string;
residentReportId?: number;
onPhotoSelect?: (photo: WorkPhoto) => void;
}
export const WorkPhotosManager: React.FC<WorkPhotosManagerProps> = ({
buildingId,
residentReportId,
onPhotoSelect
}) => {
const [photos, setPhotos] = useState<WorkPhoto[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false);
useEffect(() => {
loadPhotos();
}, [buildingId, residentReportId]);
const loadPhotos = async () => {
try {
setIsLoading(true);
const params = new URLSearchParams();
if (buildingId) params.append('building_id', buildingId);
if (residentReportId) params.append('resident_report_id', String(residentReportId));
const queryString = params.toString();
const path = `/pr/work-photos${queryString ? `?${queryString}` : ''}`;
const data = await apiClient.get<WorkPhoto[]>(path);
setPhotos(data);
} catch (err) {
console.error('Error loading work photos:', err);
setPhotos([]);
} finally {
setIsLoading(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm('Удалить фото отчет?')) return;
try {
await apiClient.delete(`/pr/work-photos/${id}`);
await loadPhotos();
} catch (err) {
console.error('Error deleting work photo:', err);
alert('Ошибка удаления фото отчета');
}
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h4 className="font-black text-slate-800 text-sm">Фото отчеты работ</h4>
<button
onClick={() => setShowCreateForm(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-xl text-xs font-black uppercase tracking-wider flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Добавить
</button>
</div>
{showCreateForm && (
<WorkPhotoCreateForm
buildingId={buildingId}
residentReportId={residentReportId}
onClose={() => setShowCreateForm(false)}
onSuccess={() => {
setShowCreateForm(false);
loadPhotos();
}}
/>
)}
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-slate-400" />
</div>
) : photos.length === 0 ? (
<div className="text-center py-8 text-slate-400 text-sm">
<ImageIcon className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Фото отчеты не найдены</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{photos.map(photo => (
<WorkPhotoCard
key={photo.id}
photo={photo}
onDelete={handleDelete}
onSelect={onPhotoSelect}
/>
))}
</div>
)}
</div>
);
};
interface WorkPhotoCardProps {
photo: WorkPhoto;
onDelete: (id: number) => void;
onSelect?: (photo: WorkPhoto) => void;
}
const WorkPhotoCard: React.FC<WorkPhotoCardProps> = ({ photo, onDelete, onSelect }) => {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api';
return (
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-all">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<h5 className="font-black text-slate-800 text-sm mb-1">{photo.workName}</h5>
<div className="flex items-center gap-2 text-xs text-slate-500">
<Calendar className="w-3 h-3" />
<span>{new Date(photo.workDate).toLocaleDateString('ru-RU')}</span>
</div>
{photo.description && (
<p className="text-xs text-slate-600 mt-2">{photo.description}</p>
)}
</div>
<button
onClick={() => onDelete(photo.id)}
className="p-1.5 text-slate-400 hover:text-red-500 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
{photo.photoBeforeUrl ? (
<div className="relative aspect-video bg-slate-100 rounded-lg overflow-hidden">
<img
src={`${API_BASE_URL.replace('/api', '')}${photo.photoBeforeUrl}`}
alt="До"
className="w-full h-full object-cover"
/>
<div className="absolute bottom-1 left-1 bg-black/50 text-white text-[10px] px-1.5 py-0.5 rounded">
До
</div>
</div>
) : (
<div className="aspect-video bg-slate-100 rounded-lg flex items-center justify-center">
<ImageIcon className="w-8 h-8 text-slate-300" />
</div>
)}
{photo.photoAfterUrl ? (
<div className="relative aspect-video bg-slate-100 rounded-lg overflow-hidden">
<img
src={`${API_BASE_URL.replace('/api', '')}${photo.photoAfterUrl}`}
alt="После"
className="w-full h-full object-cover"
/>
<div className="absolute bottom-1 left-1 bg-black/50 text-white text-[10px] px-1.5 py-0.5 rounded">
После
</div>
</div>
) : (
<div className="aspect-video bg-slate-100 rounded-lg flex items-center justify-center">
<ImageIcon className="w-8 h-8 text-slate-300" />
</div>
)}
</div>
</div>
);
};
interface WorkPhotoCreateFormProps {
buildingId?: string;
residentReportId?: number;
onClose: () => void;
onSuccess: () => void;
}
const WorkPhotoCreateForm: React.FC<WorkPhotoCreateFormProps> = ({
buildingId,
residentReportId,
onClose,
onSuccess
}) => {
const [formData, setFormData] = useState({
building_id: buildingId || '',
resident_report_id: residentReportId || '',
work_name: '',
work_date: new Date().toISOString().split('T')[0],
description: ''
});
const [photoBefore, setPhotoBefore] = useState<File | null>(null);
const [photoAfter, setPhotoAfter] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.building_id || !formData.work_name || !formData.work_date) {
alert('Заполните обязательные поля');
return;
}
try {
setIsSubmitting(true);
const formDataToSend = new FormData();
formDataToSend.append('building_id', formData.building_id);
if (formData.resident_report_id) {
formDataToSend.append('resident_report_id', String(formData.resident_report_id));
}
formDataToSend.append('work_name', formData.work_name);
formDataToSend.append('work_date', formData.work_date);
if (formData.description) {
formDataToSend.append('description', formData.description);
}
if (photoBefore) {
formDataToSend.append('photo_before', photoBefore);
}
if (photoAfter) {
formDataToSend.append('photo_after', photoAfter);
}
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api';
const response = await fetch(`${API_BASE_URL}/pr/work-photos`, {
method: 'POST',
body: formDataToSend
});
if (!response.ok) {
throw new Error('Ошибка создания фото отчета');
}
onSuccess();
} catch (err) {
console.error('Error creating work photo:', err);
alert('Ошибка создания фото отчета');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-3xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-black text-slate-800">Добавить фото отчет</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-xl">
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{!buildingId && (
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Дом (ID)
</label>
<input
type="text"
value={formData.building_id}
onChange={e => setFormData({ ...formData, building_id: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
/>
</div>
)}
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Название работы
</label>
<input
type="text"
value={formData.work_name}
onChange={e => setFormData({ ...formData, work_name: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
placeholder="Например: Ремонт подъезда"
required
/>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Дата работы
</label>
<input
type="date"
value={formData.work_date}
onChange={e => setFormData({ ...formData, work_date: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
required
/>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Описание (что сделано)
</label>
<textarea
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
className="w-full p-3 border border-slate-200 rounded-xl text-sm resize-none"
rows={3}
placeholder="Опишите выполненные работы..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Фото "До"
</label>
<input
type="file"
accept="image/*"
onChange={e => setPhotoBefore(e.target.files?.[0] || null)}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
/>
</div>
<div>
<label className="block text-xs font-black text-slate-600 uppercase tracking-wider mb-2">
Фото "После"
</label>
<input
type="file"
accept="image/*"
onChange={e => setPhotoAfter(e.target.files?.[0] || null)}
className="w-full p-3 border border-slate-200 rounded-xl text-sm"
/>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-6 py-3 bg-primary-600 text-white rounded-xl text-xs font-black uppercase tracking-widest disabled:opacity-50"
>
{isSubmitting ? 'Создание...' : 'Создать фото отчет'}
</button>
<button
type="button"
onClick={onClose}
className="px-6 py-3 bg-slate-100 text-slate-600 rounded-xl text-xs font-black uppercase tracking-widest"
>
Отмена
</button>
</div>
</form>
</div>
</div>
);
};