Initial commit MKD fixes
This commit is contained in:
1452
components/pr/BuildingReportPage.tsx
Executable file
1452
components/pr/BuildingReportPage.tsx
Executable file
File diff suppressed because it is too large
Load Diff
918
components/pr/EventsRegistry.tsx
Executable file
918
components/pr/EventsRegistry.tsx
Executable 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
367
components/pr/NPSSurveyPage.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
403
components/pr/NPSSurveyStatsPage.tsx
Executable file
403
components/pr/NPSSurveyStatsPage.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
457
components/pr/NPSSurveysManager.tsx
Executable file
457
components/pr/NPSSurveysManager.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
497
components/pr/NegativeResolution.tsx
Executable file
497
components/pr/NegativeResolution.tsx
Executable 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
812
components/pr/PRFeedbackFeed.tsx
Executable 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
200
components/pr/PRSummary.tsx
Executable 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>
|
||||
);
|
||||
393
components/pr/PostTopicsManager.tsx
Executable file
393
components/pr/PostTopicsManager.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
456
components/pr/PublicationSchedule.tsx
Executable file
456
components/pr/PublicationSchedule.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
122
components/pr/ResidentReportPage.tsx
Executable file
122
components/pr/ResidentReportPage.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
410
components/pr/ResidentReportView.tsx
Executable file
410
components/pr/ResidentReportView.tsx
Executable 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
699
components/pr/ResidentReports.tsx
Executable 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
1015
components/pr/SMMManager.tsx
Executable file
File diff suppressed because it is too large
Load Diff
316
components/pr/WorkPhotosDirectory.tsx
Executable file
316
components/pr/WorkPhotosDirectory.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
358
components/pr/WorkPhotosManager.tsx
Executable file
358
components/pr/WorkPhotosManager.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user