Initial commit MKD fixes

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

View File

@@ -0,0 +1,196 @@
import React, { useMemo } from 'react';
import {
Inbox,
Clock,
CheckCircle2,
AlertTriangle,
TrendingUp,
Activity,
Users,
Zap
} from 'lucide-react';
import { DomaApplication } from '../../types';
interface Props {
applications: DomaApplication[];
onNavigate: (tab: any) => void;
}
export const AppSummary: React.FC<Props> = ({ applications, onNavigate }) => {
// Используем поле isOverdue из БД, если есть, иначе вычисляем
const stats = {
new: applications.filter(a => a.status === 'new').length,
inProgress: applications.filter(a => a.status === 'in_progress').length,
deferred: applications.filter(a => a.status === 'deferred').length,
done: applications.filter(a => a.status === 'done').length,
overdue: applications.filter(a =>
a.isOverdue !== undefined
? a.isOverdue
: (a.status !== 'done' && a.status !== 'canceled' && new Date(a.deadlineAt) < new Date())
).length
};
const totalActive = stats.new + stats.inProgress + stats.deferred;
const completionRate = applications.length > 0 ? Math.round((stats.done / (stats.done + totalActive)) * 100) : 0;
// Группируем заявки по исполнителям для "Загрузка мастеров"
const performerStats = useMemo(() => {
const map = new Map<string, number>();
applications.forEach(app => {
if (app.performer?.name && app.status !== 'done' && app.status !== 'canceled') {
const count = map.get(app.performer.name) || 0;
map.set(app.performer.name, count + 1);
}
});
return Array.from(map.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 4); // Топ-4 исполнителя
}, [applications]);
return (
<div className="space-y-6 animate-fade-in">
{/* Real-time KPI Cards */}
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<StatCard
icon={Inbox}
label="Новые"
value={stats.new}
color="text-red-600"
bg="bg-red-50"
onClick={() => onNavigate('registry')}
/>
<StatCard
icon={Activity}
label="В работе"
value={stats.inProgress}
color="text-blue-600"
bg="bg-blue-50"
onClick={() => onNavigate('registry')}
/>
<StatCard
icon={Clock}
label="Отложено"
value={stats.deferred}
color="text-orange-600"
bg="bg-orange-50"
onClick={() => onNavigate('registry')}
/>
<StatCard
icon={CheckCircle2}
label="Выполнено"
value={stats.done}
color="text-emerald-600"
bg="bg-emerald-50"
/>
<StatCard
icon={AlertTriangle}
label="Просрочено"
value={stats.overdue}
color="text-amber-600"
bg="bg-amber-50"
onClick={() => onNavigate('control')}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Progress Widget */}
<div className="lg:col-span-2 bg-slate-900 rounded-[2.5rem] p-8 text-white shadow-xl relative overflow-hidden">
<Zap className="absolute -top-4 -right-4 w-40 h-40 opacity-10 rotate-12 text-primary-400" />
<div className="relative z-10">
<div className="flex justify-between items-start mb-8">
<div>
<h3 className="text-3xl font-black mb-2">Производительность</h3>
<p className="text-slate-400 text-sm font-medium">Эффективность закрытия заявок за сегодня</p>
</div>
<div className="text-right">
<p className="text-5xl font-black text-primary-400">{completionRate}%</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500 mt-1">Индекс SLA</p>
</div>
</div>
<div className="space-y-6">
<StatusLine
label="Новые заявки"
value={applications.length > 0 ? Math.round((stats.new / applications.length) * 100) : 0}
color="bg-red-500"
/>
<StatusLine
label="В работе"
value={applications.length > 0 ? Math.round((stats.inProgress / applications.length) * 100) : 0}
color="bg-blue-500"
/>
<StatusLine
label="Выполнено"
value={completionRate}
color="bg-emerald-500"
/>
</div>
</div>
</div>
{/* Performer Load */}
<div className="bg-white rounded-[2.5rem] p-6 border border-slate-200 shadow-sm">
<h4 className="font-black text-slate-800 text-[10px] uppercase tracking-widest mb-6 flex items-center gap-2">
<Users className="w-4 h-4 text-primary-500"/> Загрузка мастеров
</h4>
<div className="space-y-4">
{performerStats.length > 0 ? (
performerStats.map((perf, idx) => (
<PerformerItem
key={idx}
name={perf.name}
count={perf.count}
warning={perf.count > 5}
/>
))
) : (
<p className="text-xs text-slate-400 italic text-center py-4">Нет активных заявок</p>
)}
</div>
<button onClick={() => onNavigate('control')} className="w-full mt-6 py-3 bg-slate-50 hover:bg-slate-100 rounded-2xl text-[10px] font-black uppercase tracking-widest text-slate-500 transition-colors">
Перераспределить
</button>
</div>
</div>
</div>
);
};
const StatCard = ({ icon: Icon, label, value, color, bg, onClick }: any) => (
<div
onClick={onClick}
className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm cursor-pointer hover:border-primary-400 transition-all hover:shadow-md active:scale-95"
>
<div className="flex justify-between items-start mb-3">
<div className={`p-2.5 ${bg} ${color} rounded-2xl`}>
<Icon className="w-5 h-5"/>
</div>
{value > 5 && label === 'Новые' && <span className="w-2 h-2 rounded-full bg-red-500 animate-ping"/>}
</div>
<p className="text-3xl font-black text-slate-800 leading-none">{value}</p>
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-wider mt-2">{label}</p>
</div>
);
const StatusLine = ({ label, value, color }: any) => (
<div className="space-y-1.5">
<div className="flex justify-between text-[10px] font-bold uppercase text-slate-400">
<span>{label}</span>
<span className="text-white">{value}%</span>
</div>
<div className="h-1.5 w-full bg-white/10 rounded-full overflow-hidden">
<div className={`h-full ${color} rounded-full transition-all duration-1000`} style={{ width: `${value}%` }} />
</div>
</div>
);
const PerformerItem = ({ name, count, warning }: any) => (
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-2xl border border-slate-100">
<span className="text-xs font-bold text-slate-700 truncate mr-2">{name}</span>
<span className={`px-2 py-0.5 rounded-lg text-[10px] font-black ${warning ? 'bg-red-100 text-red-600' : 'bg-primary-100 text-primary-600'}`}>
{count} заяв.
</span>
</div>
);

View File

@@ -0,0 +1,451 @@
import React, { useState, useEffect } from 'react';
import {
X,
Star,
ChevronDown,
Calendar,
MapPin,
User,
Phone,
FileText,
HardHat,
Users,
History,
MessageSquare,
Paperclip,
Send,
Edit3,
Printer,
} from 'lucide-react';
import { DomaApplication, DomaApplicationStatus, ApplicationComment, ApplicationHistoryEntry } from '../../types';
import { backendApi } from '../../services/apiClient';
import { EditApplicationModal } from './EditApplicationModal';
import { StatusChangeModal } from './StatusChangeModal';
const STATUS_LABELS: Record<DomaApplicationStatus, string> = {
new: 'Новая',
in_progress: 'В работе',
deferred: 'Отложена',
done: 'Выполнена',
canceled: 'Отменена',
};
const SOURCE_LABELS: Record<string, string> = {
call: 'Звонок',
website: 'Заявка с сайта',
reception: 'Личный приём',
mobile: 'Мобильное приложение',
other: 'Другое',
};
const STATUS_STYLE: Record<DomaApplicationStatus, { bg: string; text: string }> = {
new: { bg: 'bg-red-100', text: 'text-red-700' },
in_progress: { bg: 'bg-blue-100', text: 'text-blue-700' },
deferred: { bg: 'bg-orange-100', text: 'text-orange-700' },
done: { bg: 'bg-emerald-100', text: 'text-emerald-700' },
canceled: { bg: 'bg-slate-200', text: 'text-slate-600' },
};
const MAX_COMMENT_LENGTH = 1000;
interface Props {
applicationId: number;
onClose: () => void;
onUpdated?: () => void;
}
export const ApplicationCardDetail: React.FC<Props> = ({ applicationId, onClose, onUpdated }) => {
const [application, setApplication] = useState<DomaApplication | null>(null);
const [history, setHistory] = useState<ApplicationHistoryEntry[]>([]);
const [comments, setComments] = useState<ApplicationComment[]>([]);
const [commentTab, setCommentTab] = useState<'internal' | 'resident'>('internal');
const [newComment, setNewComment] = useState('');
const [loading, setLoading] = useState(true);
const [statusDropdownOpen, setStatusDropdownOpen] = useState(false);
const [sendingComment, setSendingComment] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [statusModalTarget, setStatusModalTarget] = useState<DomaApplicationStatus | null>(null);
const loadApplication = () => {
backendApi.getApplication(applicationId).then(setApplication).catch(() => setApplication(null));
};
const loadHistory = () => {
backendApi.getApplicationHistory(applicationId).then(setHistory).catch(() => setHistory([]));
};
const loadComments = () => {
backendApi.getApplicationComments(applicationId).then(setComments).catch(() => setComments([]));
};
useEffect(() => {
setLoading(true);
loadApplication();
loadHistory();
loadComments();
setLoading(false);
}, [applicationId]);
const handleQuickStatusChange = async (newStatus: DomaApplicationStatus) => {
if (!application) return;
if (newStatus === 'canceled' || newStatus === 'deferred') {
setStatusDropdownOpen(false);
setStatusModalTarget(newStatus);
return;
}
try {
await backendApi.updateApplication(applicationId, { status: newStatus, changedBy: 'Администратор' });
window.dispatchEvent(new CustomEvent('mkd-applications-changed'));
loadApplication();
loadHistory();
onUpdated?.();
setStatusDropdownOpen(false);
} catch (err) {
console.error(err);
}
};
const handleStatusModalConfirm = async (reason: string, deferredUntil?: string) => {
if (!statusModalTarget || !application) return;
try {
await backendApi.updateApplication(applicationId, {
status: statusModalTarget,
statusReason: reason,
deadlineAt: deferredUntil ? new Date(deferredUntil + 'T12:00:00').toISOString() : undefined,
changedBy: 'Администратор',
});
window.dispatchEvent(new CustomEvent('mkd-applications-changed'));
loadApplication();
loadHistory();
onUpdated?.();
setStatusModalTarget(null);
} catch (err) {
throw err;
}
};
const handleSendComment = async () => {
const text = newComment.trim();
if (!text || sendingComment) return;
setSendingComment(true);
try {
await backendApi.addApplicationComment(applicationId, {
text,
type: commentTab,
authorName: 'Администратор',
});
setNewComment('');
loadComments();
} catch (err) {
console.error(err);
} finally {
setSendingComment(false);
}
};
const formatDate = (s: string) => {
const d = new Date(s);
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const formatHistoryField = (fieldName: string) => {
const map: Record<string, string> = {
status: 'Статус',
deadline_at: 'Срок заявки',
executor_name: 'Исполнитель',
responsible_name: 'Ответственный',
};
return map[fieldName] || fieldName;
};
const formatHistoryEntry = (entry: ApplicationHistoryEntry) => {
if (entry.fieldName === 'status_change' && entry.newValue) {
return `${entry.changedBy}: ${entry.newValue}`;
}
if (entry.fieldName === 'deadline_at' && entry.oldValue && entry.newValue) {
try {
const oldD = new Date(entry.oldValue).toLocaleDateString('ru-RU');
const newD = new Date(entry.newValue).toLocaleDateString('ru-RU');
return `${entry.changedBy} изменил срок заявки с ${oldD} на ${newD}`;
} catch {
return `${entry.changedBy} изменил ${formatHistoryField(entry.fieldName)}`;
}
}
return `${entry.changedBy} изменил ${formatHistoryField(entry.fieldName)}`;
};
if (loading || !application) {
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in">
<div className="bg-white rounded-3xl w-full max-w-2xl p-12 text-center text-slate-500 shadow-2xl">
{loading ? 'Загрузка…' : 'Заявка не найдена'}
</div>
</div>
);
}
const statusLabel = STATUS_LABELS[application.status] || application.status;
const statusStyle = STATUS_STYLE[application.status];
const isOverdue = application.isOverdue || (application.status !== 'done' && application.status !== 'canceled' && new Date(application.deadlineAt) < new Date());
const sourceLabel = application.sourceChannel ? SOURCE_LABELS[application.sourceChannel] || application.sourceChannel : 'Не указан';
const handlePrint = () => window.print();
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm overflow-y-auto animate-fade-in print:bg-white print:p-0">
<style>{`@media print { body * { visibility: hidden } .print-application-card, .print-application-card * { visibility: visible } .print-application-card { position: absolute !important; left: 0 !important; top: 0 !important; width: 100% !important; max-width: 100% !important; box-shadow: none !important; } }`}</style>
<div className="print-application-card bg-white rounded-3xl w-full max-w-3xl max-h-[95vh] overflow-y-auto shadow-2xl my-8 animate-scale-in">
{/* Header */}
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 rounded-t-3xl z-10 flex justify-between items-start">
<div>
<h1 className="text-lg font-black text-slate-800">Заявка {application.number}</h1>
<p className="text-xs text-slate-500 mt-1">
Дата создания {formatDate(application.createdAt)}, автор Администратор (вы)
</p>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mt-0.5">Источник {sourceLabel}</p>
</div>
<div className="flex items-center gap-2">
<button type="button" className="p-2 text-slate-400 hover:text-amber-500 rounded-xl transition-colors hover:bg-slate-100" title="В избранное">
<Star className="w-5 h-5" />
</button>
<div className="relative">
<button
type="button"
onClick={() => setStatusDropdownOpen(!statusDropdownOpen)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl font-black text-xs uppercase tracking-wider transition-colors ${statusStyle.bg} ${statusStyle.text}`}
>
{statusLabel}
<ChevronDown className="w-4 h-4" />
</button>
{statusDropdownOpen && (
<>
<div className="fixed inset-0 z-0" onClick={() => setStatusDropdownOpen(false)} />
<div className="absolute right-0 top-full mt-1 py-1 bg-white border border-slate-200 rounded-xl shadow-xl z-20 min-w-[160px]">
{(Object.keys(STATUS_LABELS) as DomaApplicationStatus[]).map((s) => (
<button
key={s}
type="button"
onClick={() => handleQuickStatusChange(s)}
className="w-full px-4 py-2.5 text-left text-xs font-black text-slate-700 uppercase tracking-wider hover:bg-slate-50 rounded-lg"
>
{STATUS_LABELS[s]}
</button>
))}
</div>
</>
)}
</div>
<button type="button" onClick={onClose} className="p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600 rounded-xl transition-colors">
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Icons (muted call / doc) */}
<div className="px-6 py-2 flex gap-2 text-slate-400">
<span className="p-1.5 rounded-xl border border-slate-200" title="Звонки отключены">
<Phone className="w-4 h-4" />
</span>
<span className="p-1.5 rounded-xl border border-slate-200" title="Уведомления">
<FileText className="w-4 h-4" />
</span>
</div>
{/* Fields */}
<div className="px-6 py-4 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Выполнить до</label>
<p className="text-sm font-medium text-slate-800 flex items-center gap-1">
<Calendar className="w-4 h-4 text-slate-400" />
{formatDate(application.deadlineAt)}
{isOverdue && <span className="text-red-600 text-xs font-bold">(просрочена на день)</span>}
</p>
</div>
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Адрес</label>
<p className="text-sm font-medium text-slate-800 flex items-center gap-1">
<MapPin className="w-4 h-4 text-slate-400 shrink-0" />
{application.address}
{application.apartment && ` квартира ${application.apartment}`}
</p>
</div>
</div>
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Заявитель (житель)</label>
<p className="text-sm font-medium text-slate-800 flex items-center gap-2">
<User className="w-4 h-4 text-slate-400" />
{application.contactName || application.clientName || 'Не указан'}
{application.contactPhone && (
<a href={`tel:${application.contactPhone}`} className="text-primary-600 hover:underline flex items-center gap-1">
<Phone className="w-3.5 h-3.5" /> {application.contactPhone}
</a>
)}
</p>
</div>
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Проблема</label>
<p className="text-sm text-slate-800">{application.description}</p>
</div>
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Классификатор</label>
<p className="text-sm text-slate-800">
{application.placeIncident || '—'}
{application.workType && ` » ${application.workType}`}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Исполнитель</label>
<p className="text-sm text-slate-800 flex items-center gap-1">
<HardHat className="w-4 h-4 text-slate-400" />
{application.executorName || application.performer?.name || 'Сотрудник не указан или был удален'}
</p>
</div>
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Ответственный</label>
<p className="text-sm text-slate-800 flex items-center gap-1">
<User className="w-4 h-4 text-slate-400" />
{application.responsibleName || 'Не указан'}
</p>
</div>
</div>
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Наблюдатели</label>
<p className="text-sm text-slate-800 flex items-center gap-1">
<Users className="w-4 h-4 text-slate-400" />
{application.observersText || 'Сотрудники не указаны или были удалены'}
</p>
</div>
</div>
{/* История изменений заявки */}
<div className="px-6 py-4 border-t border-slate-200">
<h3 className="text-sm font-black text-slate-800 uppercase tracking-wider mb-3 flex items-center gap-2">
<History className="w-4 h-4 text-primary-500" />
История изменений заявки
</h3>
<ul className="space-y-2">
{history.length === 0 ? (
<li className="text-sm text-slate-400 italic">Нет записей</li>
) : (
history.map((entry) => (
<li key={entry.id} className="text-sm text-slate-600">
{formatDate(entry.changedAt)}: {formatHistoryEntry(entry)}
</li>
))
)}
</ul>
</div>
{/* Комментарии: Внутренние / С жителем */}
<div className="px-6 py-4 border-t border-slate-200">
<h3 className="text-sm font-black text-slate-800 uppercase tracking-wider mb-3 flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-primary-500" />
Комментарии
</h3>
<div className="flex p-1 bg-slate-200/50 rounded-2xl gap-1 mb-4">
<button
type="button"
onClick={() => setCommentTab('internal')}
className={`flex-1 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all ${commentTab === 'internal' ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
Внутренние{comments.filter((c) => c.type === 'internal').length ? ` (${comments.filter((c) => c.type === 'internal').length})` : ''}
</button>
<button
type="button"
onClick={() => setCommentTab('resident')}
className={`flex-1 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all ${commentTab === 'resident' ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
С жителем{comments.filter((c) => c.type === 'resident').length ? ` (${comments.filter((c) => c.type === 'resident').length})` : ''}
</button>
</div>
<div className="max-h-40 overflow-y-auto space-y-2 mb-4">
{comments.filter((c) => c.type === commentTab).length === 0 ? (
<p className="text-sm text-slate-400 italic">Нет комментариев</p>
) : (
comments
.filter((c) => c.type === commentTab)
.map((c) => (
<div key={c.id} className="bg-slate-50 rounded-xl p-3">
<p className="text-xs font-bold text-slate-500">
{c.authorName} (сотрудник) · {formatDate(c.createdAt)}
</p>
<p className="text-sm text-slate-800 mt-0.5">{c.text}</p>
</div>
))
)}
</div>
<div className="flex gap-2">
<input
type="text"
value={newComment}
onChange={(e) => setNewComment(e.target.value.slice(0, MAX_COMMENT_LENGTH))}
placeholder="Ваш комментарий"
className="flex-1 px-4 py-2.5 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<span className="text-[10px] font-bold text-slate-400 self-center uppercase">{newComment.length}/{MAX_COMMENT_LENGTH}</span>
<button
type="button"
onClick={handleSendComment}
disabled={!newComment.trim() || sendingComment}
className="p-2.5 bg-primary-600 text-white rounded-xl hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
<Send className="w-5 h-5" />
</button>
</div>
<div className="flex gap-2 mt-2 text-slate-400">
<button type="button" className="p-1.5 rounded-xl hover:bg-slate-100 transition-colors" title="Прикрепить файл">
<Paperclip className="w-4 h-4" />
</button>
</div>
</div>
{/* Действия */}
<div className="sticky bottom-0 bg-white border-t border-slate-200 px-6 py-4 rounded-b-3xl flex flex-wrap gap-2 print:border-t print:pt-4">
<button
type="button"
onClick={() => setEditOpen(true)}
className="px-4 py-2.5 bg-slate-900 text-white rounded-xl font-black text-xs uppercase tracking-wider hover:bg-slate-800 transition-colors flex items-center gap-2 print:hidden"
>
<Edit3 className="w-4 h-4" /> Редактировать
</button>
<button
type="button"
onClick={handlePrint}
className="px-4 py-2.5 bg-white border border-slate-200 rounded-xl font-black text-xs uppercase tracking-wider text-slate-700 hover:bg-slate-50 flex items-center gap-2 print:hidden"
>
<Printer className="w-4 h-4" /> Печать
</button>
</div>
</div>
{editOpen && application && (
<EditApplicationModal
isOpen={editOpen}
onClose={() => setEditOpen(false)}
application={application}
onSuccess={() => {
loadApplication();
loadHistory();
onUpdated?.();
}}
/>
)}
{statusModalTarget && application && (statusModalTarget === 'canceled' || statusModalTarget === 'deferred') && (
<StatusChangeModal
isOpen
onClose={() => setStatusModalTarget(null)}
application={{ id: application.id, number: application.number, address: application.address, currentStatus: application.status }}
newStatus={statusModalTarget}
onConfirm={handleStatusModalConfirm}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,147 @@
import React, { useState, useMemo } from 'react';
import { DomaApplication, DomaApplicationStatus } from '../../types';
import { Search, Clock, User, HardHat, ExternalLink, RotateCw } from 'lucide-react';
import { ApplicationCardDetail } from './ApplicationCardDetail';
interface Props {
applications: DomaApplication[];
onRefresh?: () => void;
}
const statusConfig = {
new: { label: 'Новая', color: 'bg-red-500', textColor: 'text-red-600', bgColor: 'bg-red-100' },
in_progress: { label: 'В работе', color: 'bg-blue-500', textColor: 'text-blue-600', bgColor: 'bg-blue-100' },
deferred: { label: 'Отложена', color: 'bg-orange-500', textColor: 'text-orange-600', bgColor: 'bg-orange-100' },
done: { label: 'Выполнена', color: 'bg-emerald-500', textColor: 'text-emerald-600', bgColor: 'bg-emerald-100' },
canceled: { label: 'Отменена', color: 'bg-slate-400', textColor: 'text-slate-600', bgColor: 'bg-slate-100' },
};
export const ApplicationsRegistry: React.FC<Props> = ({ applications, onRefresh }) => {
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<DomaApplicationStatus | 'all'>('all');
const [openCardId, setOpenCardId] = useState<number | null>(null);
const filtered = useMemo(() => {
return applications.filter(app => {
const matchesSearch = app.address.toLowerCase().includes(search.toLowerCase()) || app.number.toLowerCase().includes(search.toLowerCase());
const matchesFilter = filter === 'all' || app.status === filter;
return matchesSearch && matchesFilter;
}).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [applications, search, filter]);
return (
<div className="space-y-4 animate-fade-in">
<div className="flex flex-col md:flex-row gap-4">
<div className="relative flex-1 flex gap-2">
<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"
/>
{onRefresh && (
<button
type="button"
onClick={() => window.dispatchEvent(new CustomEvent('mkd-applications-changed'))}
className="shrink-0 p-3 bg-white border border-slate-200 rounded-2xl text-slate-400 hover:text-primary-600 hover:border-primary-300 transition-all"
title="Обновить реестр"
>
<RotateCw className="w-5 h-5" />
</button>
)}
</div>
<div className="flex bg-slate-200/50 p-1 rounded-2xl shrink-0 overflow-x-auto no-scrollbar">
{['all', 'new', 'in_progress', 'deferred', 'done'].map((f) => (
<button
key={f}
onClick={() => setFilter(f as any)}
className={`flex-shrink-0 min-w-[5rem] px-4 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all whitespace-nowrap ${filter === f ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
{f === 'all' ? 'Все' : f === 'new' ? 'Новые' : f === 'in_progress' ? 'В работе' : f === 'deferred' ? 'Отложены' : 'Готово'}
</button>
))}
</div>
</div>
<div className="space-y-3">
{filtered.map(app => (
<ApplicationCard key={app.id} application={app} onOpenCard={() => setOpenCardId(app.id)} />
))}
{filtered.length === 0 && (
<div className="py-20 text-center text-slate-400 italic">Заявки не найдены</div>
)}
</div>
{openCardId !== null && (
<ApplicationCardDetail
applicationId={openCardId}
onClose={() => setOpenCardId(null)}
onUpdated={onRefresh}
/>
)}
</div>
);
};
const ApplicationCard: React.FC<{ application: DomaApplication; onOpenCard: () => void }> = ({ application, onOpenCard }) => {
const { label, color, textColor, bgColor } = statusConfig[application.status];
const timeSince = (dateStr: string) => {
const date = new Date(dateStr);
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
if (seconds < 3600) return Math.floor(seconds/60) + " мин. назад";
if (seconds < 86400) return Math.floor(seconds/3600) + " ч. назад";
return Math.floor(seconds/86400) + " д. назад";
};
const handleCardClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest('a[href]')) return;
onOpenCard();
};
return (
<div
onClick={handleCardClick}
className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm hover:shadow-md hover:border-primary-300 transition-all cursor-pointer group relative overflow-hidden"
>
<div className={`absolute left-0 top-0 bottom-0 w-1.5 ${color}`} />
<div className="pl-3">
<div className="flex justify-between items-start mb-3">
<div>
<span className={`px-2 py-0.5 text-[9px] font-black rounded-full uppercase tracking-tighter ${bgColor} ${textColor}`}>{label}</span>
<p className="text-[10px] text-slate-400 font-bold mt-1 uppercase"> {application.number}</p>
</div>
{application.domaId && (
<a
href={`https://condo.d.doma.ai/ticket/${application.domaId}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1 text-slate-400 group-hover:text-primary-600 transition-colors"
>
<ExternalLink className="w-4 h-4" />
<span className="text-[9px] font-bold uppercase">Открыть в Doma.AI</span>
</a>
)}
</div>
<h3 className="font-black text-slate-800 text-sm mb-1 group-hover:text-primary-600 transition-colors">{application.address}, кв. {application.apartment}</h3>
<p className="text-xs text-slate-500 leading-relaxed line-clamp-2">{application.description}</p>
<div className="mt-4 pt-4 border-t border-slate-50 flex flex-wrap gap-4 text-[10px] text-slate-400 font-bold uppercase tracking-tight">
<div className="flex items-center gap-1.5"><Clock className="w-3 h-3" /> {timeSince(application.createdAt)}</div>
<div className="flex items-center gap-1.5"><User className="w-3 h-3" /> {application.clientName}</div>
{application.performer && (
<div className="flex items-center gap-1.5 text-primary-600">
<HardHat className="w-3 h-3"/> {application.performer.name}
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,490 @@
import React, { useState, useEffect, useMemo } from 'react';
import { X, Paperclip, HelpCircle, Calendar } from 'lucide-react';
import { CreateApplicationPayload } from '../../types';
import { backendApi } from '../../services/apiClient';
import { Building, Employee } from '../../types';
type ResidentOption = { key: string; fullName: string; apartment: string; phone?: string };
function residentsFromBuilding(building: Building | null): ResidentOption[] {
if (!building?.accounts?.length) return [];
const list: ResidentOption[] = [];
for (const acc of building.accounts) {
const apt = acc.apartmentNumber || '';
(acc.owners || []).forEach((o) => {
if (o?.fullName) list.push({ key: `o:${acc.id}:${o.fullName}`, fullName: o.fullName, apartment: apt, phone: o.phone });
});
(acc.registered || []).forEach((r) => {
if (r?.fullName) list.push({ key: `r:${acc.id}:${r.id}`, fullName: r.fullName, apartment: apt, phone: r.phone });
});
}
const seen = new Set<string>();
return list.filter((r) => {
const k = `${r.apartment}::${r.fullName}`.toLowerCase();
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}
const SOURCE_CHANNELS = [
{ value: '', label: 'Выбрать' },
{ value: 'call', label: 'Звонок' },
{ value: 'website', label: 'Заявка с сайта' },
{ value: 'reception', label: 'Личный приём' },
{ value: 'mobile', label: 'Мобильное приложение' },
{ value: 'other', label: 'Другое' },
];
const WORK_TYPES = [
{ value: '', label: 'Выбрать' },
{ value: 'plumbing', label: 'Сантехника' },
{ value: 'electrical', label: 'Электрика' },
{ value: 'heating', label: 'Отопление' },
{ value: 'elevator', label: 'Лифт' },
{ value: 'common', label: 'Общедомовое имущество' },
{ value: 'other', label: 'Другое' },
];
const PROBLEM_DETAIL_OPTIONS = [
{ value: '', label: 'Выбрать' },
{ value: 'leak', label: 'Протечка' },
{ value: 'no_water', label: 'Нет воды' },
{ value: 'no_heat', label: 'Нет отопления' },
{ value: 'no_power', label: 'Нет электричества' },
{ value: 'blockage', label: 'Засор' },
{ value: 'other', label: 'Другое' },
];
const MAX_DESCRIPTION_LENGTH = 700;
interface Props {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export const CreateApplicationCard: React.FC<Props> = ({ isOpen, onClose, onSuccess }) => {
const [buildingId, setBuildingId] = useState('');
const [address, setAddress] = useState('');
const [apartment, setApartment] = useState('');
const [sourceChannel, setSourceChannel] = useState('');
const [isFromResident, setIsFromResident] = useState(true);
const [contactPhone, setContactPhone] = useState('');
const [contactName, setContactName] = useState('');
const [selectedResidentKey, setSelectedResidentKey] = useState('');
const [description, setDescription] = useState('');
const [placeIncident, setPlaceIncident] = useState('');
const [workType, setWorkType] = useState('');
const [problemDetail, setProblemDetail] = useState('');
const [isEmergency, setIsEmergency] = useState(false);
const [isPaid, setIsPaid] = useState(false);
const [isWarranty, setIsWarranty] = useState(false);
const [deadlineAt, setDeadlineAt] = useState('');
const [executorName, setExecutorName] = useState('');
const [responsibleName, setResponsibleName] = useState('');
const [observersText, setObserversText] = useState('');
const [showInApp, setShowInApp] = useState(false);
const [buildings, setBuildings] = useState<Building[]>([]);
const [selectedBuilding, setSelectedBuilding] = useState<Building | null>(null);
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const residents = useMemo(() => residentsFromBuilding(selectedBuilding), [selectedBuilding]);
useEffect(() => {
if (isOpen) {
backendApi.getBuildings().then(setBuildings).catch(() => setBuildings([]));
backendApi.getEmployees().then(setEmployees).catch(() => setEmployees([]));
const defaultDeadline = new Date();
defaultDeadline.setDate(defaultDeadline.getDate() + 8);
setDeadlineAt(defaultDeadline.toISOString().slice(0, 10));
setBuildingId('');
setSelectedBuilding(null);
setAddress('');
setPlaceIncident('');
setSelectedResidentKey('');
setContactName('');
setContactPhone('');
setApartment('');
}
}, [isOpen]);
useEffect(() => {
if (!buildingId) {
setSelectedBuilding(null);
setAddress('');
setPlaceIncident('');
setSelectedResidentKey('');
setContactName('');
setContactPhone('');
setApartment('');
return;
}
backendApi.getBuilding(buildingId).then((b) => {
setSelectedBuilding(b);
setAddress((b.passport as any)?.address || buildingId);
setPlaceIncident(buildingId);
}).catch(() => {
setSelectedBuilding(null);
setAddress('');
setPlaceIncident('');
});
setSelectedResidentKey('');
setContactName('');
setContactPhone('');
setApartment('');
}, [buildingId]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!buildingId || !address.trim()) {
setError('Выберите адрес (дом)');
return;
}
if (isFromResident && !selectedResidentKey) {
setError('Выберите заявителя (жителя/собственника)');
return;
}
if (!description.trim()) {
setError('Опишите проблему');
return;
}
if (description.length > MAX_DESCRIPTION_LENGTH) {
setError(`Проблема не более ${MAX_DESCRIPTION_LENGTH} символов`);
return;
}
setLoading(true);
try {
const placeBuilding = buildings.find(b => b.id === placeIncident) || selectedBuilding;
const payload: CreateApplicationPayload = {
address: address.trim(),
description: description.trim(),
apartment: apartment.trim() || undefined,
sourceChannel: sourceChannel || undefined,
isFromResident,
contactPhone: contactPhone.trim() || undefined,
contactName: contactName.trim() || undefined,
placeIncident: placeBuilding?.passport?.address || placeIncident || undefined,
workType: workType || undefined,
problemDetail: problemDetail || undefined,
isEmergency,
isPaid,
isWarranty,
deadlineAt: deadlineAt ? new Date(deadlineAt).toISOString() : undefined,
executorName: executorName.trim() || undefined,
responsibleName: responsibleName.trim() || undefined,
observersText: observersText.trim() || undefined,
showInApp,
buildingId: buildingId || undefined,
};
await backendApi.createApplication(payload);
window.dispatchEvent(new CustomEvent('mkd-applications-changed'));
onSuccess();
onClose();
} catch (err: any) {
setError(err.message || 'Ошибка при создании заявки');
} finally {
setLoading(false);
}
};
const handleClose = () => {
if (!loading) onClose();
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={handleClose}>
<div className="bg-white rounded-3xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl animate-scale-in" onClick={e => e.stopPropagation()}>
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-center rounded-t-3xl z-10">
<h3 className="text-lg font-black text-slate-800">Новая заявка</h3>
<button type="button" onClick={handleClose} className="p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600 rounded-xl transition-colors disabled:opacity-50">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 text-sm px-4 py-3 rounded-xl">
{error}
</div>
)}
{/* Откуда поступила заявка * */}
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">
Откуда поступила заявка <span className="text-red-500">*</span>
</label>
<select
value={sourceChannel}
onChange={e => setSourceChannel(e.target.value)}
className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{SOURCE_CHANNELS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Адрес (дом) * */}
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">
Адрес (дом) <span className="text-red-500">*</span>
</label>
<select
value={buildingId}
onChange={e => setBuildingId(e.target.value)}
className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">Выбрать дом</option>
{buildings.map(b => (
<option key={b.id} value={b.id}>{(b.passport as any)?.address || b.id}</option>
))}
</select>
</div>
{/* Заявка от жителя / не от жителя */}
<div>
<div className="flex p-1 bg-slate-200/50 rounded-2xl gap-1">
<button
type="button"
onClick={() => setIsFromResident(true)}
className={`flex-1 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all ${isFromResident ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
Заявка от жителя
</button>
<button
type="button"
onClick={() => setIsFromResident(false)}
className={`flex-1 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all ${!isFromResident ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
Заявка не от жителя
</button>
</div>
{isFromResident ? (
<div className="mt-4">
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Заявитель (житель/собственник) <span className="text-red-500">*</span></label>
<select
value={selectedResidentKey}
onChange={e => {
const key = e.target.value;
setSelectedResidentKey(key);
const r = residents.find(x => x.key === key);
if (r) {
setContactName(r.fullName);
setContactPhone(r.phone || '');
setApartment(r.apartment);
}
}}
className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">{buildingId ? 'Выбрать жителя или собственника' : 'Сначала выберите адрес (дом)'}</option>
{residents.map(r => (
<option key={r.key} value={r.key}>{r.fullName}, кв. {r.apartment}{r.phone ? `${r.phone}` : ''}</option>
))}
</select>
{selectedResidentKey && (contactPhone || contactName) && (
<p className="text-xs text-slate-500 mt-1">
{contactName}{contactPhone ? ` · ${contactPhone}` : ''}{apartment ? ` · кв. ${apartment}` : ''}
</p>
)}
</div>
) : (
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Телефон</label>
<div className="flex">
<span className="inline-flex items-center px-3 py-2 bg-slate-100 border border-r-0 border-slate-200 rounded-l-xl text-sm text-slate-600">+7</span>
<input
type="tel"
value={contactPhone}
onChange={e => setContactPhone(e.target.value)}
placeholder="900 123-45-67"
className="flex-1 px-4 py-2 border border-slate-200 rounded-r-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">ФИО</label>
<input
type="text"
value={contactName}
onChange={e => setContactName(e.target.value)}
placeholder="Фамилия Имя Отчество"
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
)}
</div>
{/* Проблема * */}
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">
Проблема <span className="text-red-500">*</span>
</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value.slice(0, MAX_DESCRIPTION_LENGTH))}
placeholder="Опишите подробности"
rows={4}
className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-y"
/>
<p className="text-right text-xs text-slate-500 mt-1">{description.length}/{MAX_DESCRIPTION_LENGTH}</p>
</div>
{/* Добавить файл */}
<div>
<button type="button" className="inline-flex items-center gap-2 px-4 py-2.5 border border-dashed border-slate-300 rounded-xl text-xs font-black text-slate-600 uppercase tracking-wider hover:bg-slate-50 transition-colors">
<Paperclip className="w-4 h-4" /> Добавить файл
</button>
</div>
{/* Классификатор */}
<div className="space-y-4">
<h4 className="text-sm font-black text-slate-800 uppercase tracking-wider">Классификатор</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Место инцидента *</label>
<select
value={placeIncident}
onChange={e => setPlaceIncident(e.target.value)}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<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-700 mb-2 uppercase tracking-wider">Тип работ *</label>
<select
value={workType}
onChange={e => setWorkType(e.target.value)}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{WORK_TYPES.map(opt => (
<option key={opt.value || 'empty'} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">В чём проблема (не обязательно)</label>
<select
value={problemDetail}
onChange={e => setProblemDetail(e.target.value)}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{PROBLEM_DETAIL_OPTIONS.map(opt => (
<option key={opt.value || 'empty'} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
<div className="flex flex-wrap gap-4">
<label className="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isEmergency} onChange={e => setIsEmergency(e.target.checked)} className="rounded border-slate-300 text-primary-600" />
<span className="text-xs font-bold text-slate-700">Аварийная</span>
</label>
<label className="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isPaid} onChange={e => setIsPaid(e.target.checked)} className="rounded border-slate-300 text-primary-600" />
<span className="text-xs font-bold text-slate-700">Платная</span>
</label>
<label className="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isWarranty} onChange={e => setIsWarranty(e.target.checked)} className="rounded border-slate-300 text-primary-600" />
<span className="text-xs font-bold text-slate-700">Гарантийная</span>
</label>
</div>
</div>
{/* Срок выполнения */}
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">
Выполнить до <span className="text-red-500">*</span>
</label>
<div className="flex items-center gap-2">
<input
type="date"
value={deadlineAt}
onChange={e => setDeadlineAt(e.target.value)}
className="flex-1 px-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<Calendar className="w-5 h-5 text-slate-400" />
</div>
<p className="text-xs text-slate-500 mt-1 flex items-center gap-1">
<HelpCircle className="w-3.5 h-3.5" /> Дата определена автоматически (+8 дн.)
</p>
</div>
{/* Назначение заявки */}
<div className="space-y-4">
<h4 className="text-sm font-black text-slate-800 uppercase tracking-wider">Назначение заявки</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider flex items-center gap-1">
Исполнитель <HelpCircle className="w-3.5 h-3.5 text-slate-400" />
</label>
<select
value={executorName}
onChange={e => setExecutorName(e.target.value)}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">Выбрать</option>
{employees.map(emp => (
<option key={emp.id} value={emp.name}>{emp.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider flex items-center gap-1">
Ответственный <HelpCircle className="w-3.5 h-3.5 text-slate-400" />
</label>
<select
value={responsibleName}
onChange={e => setResponsibleName(e.target.value)}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">Выбрать</option>
{employees.map(emp => (
<option key={emp.id} value={emp.name}>{emp.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider flex items-center gap-1">
Наблюдатели <HelpCircle className="w-3.5 h-3.5 text-slate-400" />
</label>
<input
type="text"
value={observersText}
onChange={e => setObserversText(e.target.value)}
placeholder="ФИО через запятую"
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<label className="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={showInApp} onChange={e => setShowInApp(e.target.checked)} className="rounded border-slate-300 text-primary-600" />
<span className="text-xs font-bold text-slate-700">Отображать заявку в мобильном приложении жителя</span>
<HelpCircle className="w-3.5 h-3.5 text-slate-400" />
</label>
</div>
<div className="flex gap-3 pt-4 border-t border-slate-200">
<button type="button" onClick={handleClose} disabled={loading} className="flex-1 px-4 py-3 bg-slate-100 text-slate-700 font-black text-sm rounded-xl hover:bg-slate-200 transition-colors disabled:opacity-50">
Отмена
</button>
<button type="submit" disabled={loading} className="flex-1 px-4 py-3 bg-primary-600 text-white font-black text-sm rounded-xl hover:bg-primary-700 disabled:opacity-50 transition-colors">
{loading ? 'Создание…' : 'Создать заявку'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,365 @@
import React, { useState, useEffect } from 'react';
import { X, Calendar, HelpCircle, Sparkles } from 'lucide-react';
import { CreateOutagePayload, OutageWorkType } from '../../types';
import { backendApi } from '../../services/apiClient';
import { Building } from '../../types';
const CATEGORIES = [
{ value: '', label: 'Выбрать' },
{ value: 'water', label: 'Вода' },
{ value: 'electric', label: 'Электрика' },
{ value: 'heating', label: 'Отопление' },
{ value: 'elevator', label: 'Лифт' },
{ value: 'common', label: 'Общедомовое' },
{ value: 'other', label: 'Другое' },
];
const PROBLEMS_BY_CATEGORY: Record<string, { value: string; label: string }[]> = {
water: [
{ value: 'leak', label: 'Течь сантехнических приборов' },
{ value: 'no_water', label: 'Нет воды' },
{ value: 'blockage', label: 'Засор' },
{ value: 'other', label: 'Другое' },
],
electric: [
{ value: 'no_power', label: 'Нет электричества' },
{ value: 'repair', label: 'Ремонт сети' },
{ value: 'other', label: 'Другое' },
],
heating: [
{ value: 'no_heat', label: 'Нет отопления' },
{ value: 'repair', label: 'Ремонт системы' },
{ value: 'other', label: 'Другое' },
],
elevator: [
{ value: 'repair', label: 'Ремонт лифта' },
{ value: 'inspection', label: 'Техобслуживание' },
{ value: 'other', label: 'Другое' },
],
common: [
{ value: 'repair', label: 'Ремонт' },
{ value: 'other', label: 'Другое' },
],
other: [{ value: 'other', label: 'Другое' }],
};
const WORK_TYPES: { value: OutageWorkType; label: string }[] = [
{ value: 'absent', label: 'Отсутствует' },
{ value: 'planned', label: 'Плановый' },
{ value: 'emergency', label: 'Аварийный' },
];
const MAX_WHAT_HAPPENED = 1500;
const MAX_RESIDENT_MESSAGE = 1000;
interface Props {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
/** Если передан — форма «из дома», дом уже выбран; иначе — диспетчерская, выбор адресов */
buildingId?: string;
}
export const CreateOutageCard: React.FC<Props> = ({ isOpen, onClose, onSuccess, buildingId }) => {
const [buildingIds, setBuildingIds] = useState<string[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [startAt, setStartAt] = useState('');
const [endAt, setEndAt] = useState('');
const [category, setCategory] = useState('');
const [problemDetail, setProblemDetail] = useState('');
const [workType, setWorkType] = useState<OutageWorkType>('absent');
const [whatHappened, setWhatHappened] = useState('');
const [residentMessage, setResidentMessage] = useState('');
const [generateNews, setGenerateNews] = useState(false);
const [buildings, setBuildings] = useState<Building[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fromHouse = !!buildingId;
useEffect(() => {
if (isOpen) {
backendApi.getBuildings().then(setBuildings).catch(() => setBuildings([]));
const now = new Date();
setStartAt(now.toISOString().slice(0, 16));
setEndAt('');
if (fromHouse) {
setBuildingIds(buildingId ? [buildingId] : []);
} else {
setBuildingIds([]);
setSelectAll(false);
}
setCategory('');
setProblemDetail('');
setWorkType('absent');
setWhatHappened('');
setResidentMessage('');
setGenerateNews(false);
setError(null);
}
}, [isOpen, buildingId, fromHouse]);
useEffect(() => {
if (selectAll && !fromHouse) {
setBuildingIds(buildings.map(b => b.id));
} else if (!selectAll && !fromHouse) {
setBuildingIds([]);
}
}, [selectAll, fromHouse, buildings]);
if (!isOpen) return null;
const problemOptions = category ? (PROBLEMS_BY_CATEGORY[category] || [{ value: 'other', label: 'Другое' }]) : [];
const selectedBuildingAddresses = fromHouse
? buildings.filter(b => b.id === buildingId).map(b => (b.passport as any)?.address || b.id)
: buildings.filter(b => buildingIds.includes(b.id)).map(b => (b.passport as any)?.address || b.id);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const ids = fromHouse ? (buildingId ? [buildingId] : []) : buildingIds;
if (ids.length === 0) {
setError('Выберите хотя бы один адрес');
return;
}
if (!startAt.trim()) {
setError('Укажите начало работ');
return;
}
if (!category) {
setError('Выберите категорию');
return;
}
if (!whatHappened.trim()) {
setError('Заполните поле «Что случилось»');
return;
}
if (whatHappened.length > MAX_WHAT_HAPPENED) {
setError(`«Что случилось» — не более ${MAX_WHAT_HAPPENED} символов`);
return;
}
setLoading(true);
try {
const categoryLabel = CATEGORIES.find(c => c.value === category)?.label || category;
const payload: CreateOutagePayload = {
buildingId: ids.length === 1 ? ids[0] : undefined,
buildingIds: ids.length > 1 ? ids : undefined,
startAt: new Date(startAt).toISOString(),
endAt: endAt ? new Date(endAt).toISOString() : undefined,
type: categoryLabel,
description: whatHappened.trim(),
authorName: 'Администратор',
category: categoryLabel,
problemDetail: problemOptions.find(p => p.value === problemDetail)?.label || problemDetail || undefined,
workType,
residentMessage: residentMessage.trim() || undefined,
generateNews,
};
await backendApi.createOutage(payload);
onSuccess();
onClose();
} catch (err: any) {
setError(err.message || 'Ошибка при создании записи');
} finally {
setLoading(false);
}
};
const handleClose = () => {
if (!loading) onClose();
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={handleClose}>
<div className="bg-white rounded-3xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl animate-scale-in" onClick={e => e.stopPropagation()}>
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-center rounded-t-3xl z-10">
<h3 className="text-lg font-black text-slate-800">Новая запись</h3>
<button type="button" onClick={handleClose} className="p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600 rounded-xl transition-colors disabled:opacity-50">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 text-sm px-4 py-3 rounded-xl">
{error}
</div>
)}
{/* Адреса */}
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">
Адреса <span className="text-red-500">*</span>
</label>
{fromHouse ? (
<div className="px-4 py-3 border border-slate-200 rounded-xl text-sm text-slate-800 bg-slate-50">
{selectedBuildingAddresses[0] || buildingId || '—'}
</div>
) : (
<>
<select
multiple
value={buildingIds}
onChange={e => {
const opts = Array.from(e.target.selectedOptions, o => o.value);
setBuildingIds(opts);
}}
className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 min-h-[100px]"
>
{buildings.map(b => (
<option key={b.id} value={b.id}>{(b.passport as any)?.address || b.id}</option>
))}
</select>
<label className="inline-flex items-center gap-2 cursor-pointer mt-2">
<input type="checkbox" checked={selectAll} onChange={e => setSelectAll(e.target.checked)} className="rounded border-slate-300 text-primary-600" />
<span className="text-xs font-bold text-slate-700">Выбрать все</span>
</label>
</>
)}
</div>
{/* Начало работ */}
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">
Начало работ <span className="text-red-500">*</span>
</label>
<div className="flex items-center gap-2">
<input
type="datetime-local"
value={startAt}
onChange={e => setStartAt(e.target.value)}
className="flex-1 px-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<Calendar className="w-5 h-5 text-slate-400 shrink-0" />
</div>
</div>
{/* Завершение работ */}
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Завершение работ</label>
<div className="flex items-center gap-2">
<input
type="datetime-local"
value={endAt}
onChange={e => setEndAt(e.target.value)}
min={startAt || undefined}
className="flex-1 px-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<Calendar className="w-5 h-5 text-slate-400 shrink-0" />
</div>
</div>
{/* Категория */}
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">
Категория <span className="text-red-500">*</span>
</label>
<select
value={category}
onChange={e => {
setCategory(e.target.value);
setProblemDetail('');
}}
className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{CATEGORIES.map(opt => (
<option key={opt.value || 'empty'} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Проблема (подкатегория) */}
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Проблема</label>
<select
value={problemDetail}
onChange={e => setProblemDetail(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">{category ? 'Выбрать' : 'Выберите категорию'}</option>
{problemOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Тип работ */}
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Тип работ</label>
<div className="flex p-1 bg-slate-200/50 rounded-2xl gap-1">
{WORK_TYPES.map(opt => (
<button
key={opt.value}
type="button"
onClick={() => setWorkType(opt.value)}
className={`flex-1 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all ${workType === opt.value ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Что случилось */}
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">
Что случилось <span className="text-red-500">*</span>
</label>
<textarea
value={whatHappened}
onChange={e => setWhatHappened(e.target.value.slice(0, MAX_WHAT_HAPPENED))}
placeholder="Опишите подробности"
rows={4}
className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-y"
/>
<p className="text-right text-[10px] font-bold text-slate-500 mt-1 uppercase">{whatHappened.length}/{MAX_WHAT_HAPPENED}</p>
</div>
{/* Что сказать жителю */}
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Что сказать жителю</label>
<textarea
value={residentMessage}
onChange={e => setResidentMessage(e.target.value.slice(0, MAX_RESIDENT_MESSAGE))}
placeholder="Напишите, что скажет жителю сотрудник"
rows={3}
className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-y"
/>
<div className="flex items-center justify-between mt-1">
<button type="button" className="inline-flex items-center gap-1.5 text-xs font-bold text-slate-500 hover:text-primary-600 transition-colors">
<Sparkles className="w-3.5 h-3.5" /> Улучшить текст
</button>
<span className="text-[10px] font-bold text-slate-500 uppercase">{residentMessage.length}/{MAX_RESIDENT_MESSAGE}</span>
</div>
</div>
{/* Сгенерировать новость */}
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-xl border border-slate-100">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-slate-700">Сгенерировать новость для жителей</span>
<HelpCircle className="w-3.5 h-3.5 text-slate-400" />
</div>
<button
type="button"
role="switch"
aria-checked={generateNews}
onClick={() => setGenerateNews(!generateNews)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 ${generateNews ? 'bg-primary-600 border-primary-600' : 'bg-slate-200 border-slate-200'}`}
>
<span className={`absolute top-0.5 left-0.5 inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${generateNews ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</div>
<div className="flex gap-3 pt-4 border-t border-slate-200">
<button type="button" onClick={handleClose} disabled={loading} className="flex-1 px-4 py-3 bg-slate-100 text-slate-700 font-black text-sm rounded-xl hover:bg-slate-200 transition-colors disabled:opacity-50">
Отмена
</button>
<button type="submit" disabled={loading} className="flex-1 px-4 py-3 bg-primary-600 text-white font-black text-sm rounded-xl hover:bg-primary-700 disabled:opacity-50 transition-colors">
{loading ? 'Создание…' : 'Создать'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { DomaApplication } from '../../types';
// Added CheckCircle2 to the imports from lucide-react
import { AlertTriangle, MapPin, Phone, User, Clock, ChevronRight, Siren, ShieldAlert, CheckCircle2 } from 'lucide-react';
interface Props {
applications: DomaApplication[];
}
export const DispatcherControl: React.FC<Props> = ({ applications }) => {
const overdue = applications.filter(a => a.status !== 'done' && a.status !== 'canceled' && new Date(a.deadlineAt) < new Date());
return (
<div className="space-y-6 animate-fade-in">
{/* Urgent Alert Bar */}
<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">
<Siren 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">Требуется немедленное вмешательство в {overdue.length} заявках</p>
</div>
</div>
<button 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">
Связаться со всеми
</button>
</div>
{/* Overdue List */}
<div className="space-y-4">
<h4 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em] px-1">Журнал инцидентов</h4>
{overdue.map(app => (
<div key={app.id} className="bg-white p-5 rounded-[2rem] border-2 border-red-100 shadow-sm flex flex-col md:flex-row justify-between gap-6 hover:border-red-300 transition-all">
<div className="flex items-start gap-4">
<div className="p-4 bg-red-50 text-red-600 rounded-2xl">
<ShieldAlert className="w-7 h-7"/>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-black text-red-600 bg-red-100 px-2 py-0.5 rounded uppercase">SLA Breach</span>
<span className="text-[10px] font-bold text-slate-400"> {app.number}</span>
</div>
<h4 className="font-black text-slate-800 text-base">{app.address}, кв. {app.apartment}</h4>
<p className="text-xs text-slate-500 mt-1 font-medium italic">«{app.description}»</p>
</div>
</div>
<div className="flex items-center justify-between md:justify-end gap-6 border-t md:border-t-0 border-slate-50 pt-4 md:pt-0">
<div className="text-right">
<p className="text-red-600 font-black text-sm">Просрочено на 4ч.</p>
<p className="text-[9px] text-slate-400 font-bold uppercase mt-1">Дедлайн: {new Date(app.deadlineAt).toLocaleTimeString()}</p>
</div>
<div className="flex gap-2">
<button className="p-3 bg-slate-900 text-white rounded-xl hover:bg-slate-800 transition-colors shadow-lg">
<Phone className="w-4 h-4"/>
</button>
<button className="p-3 bg-slate-50 text-slate-400 rounded-xl hover:text-primary-600 transition-colors">
<ChevronRight className="w-4 h-4"/>
</button>
</div>
</div>
</div>
))}
{overdue.length === 0 && (
<div className="py-20 text-center text-slate-400">
{/* FIX: CheckCircle2 is now imported correctly from lucide-react */}
<CheckCircle2 className="w-12 h-12 mx-auto mb-3 opacity-20 text-emerald-500"/>
<p className="font-bold uppercase tracking-widest text-xs">Все заявки в рамках нормативов</p>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,260 @@
import React, { useEffect, useMemo, useState } from 'react';
import { backendApi } from '../../services/apiClient';
import { Building, Employee } from '../../types';
import { AlertTriangle, Check, Loader2, RefreshCw } from 'lucide-react';
type PendingItem = {
id: number;
domaValue: string;
suggestedId?: string | null;
suggestedName?: string | null;
createdAt: string;
};
type PendingResponse = {
success: boolean;
data: {
buildings: PendingItem[];
employees: PendingItem[];
};
};
interface DomaPendingMappingsProps {
onClose?: () => void;
}
export const DomaPendingMappings: React.FC<DomaPendingMappingsProps> = ({ onClose }) => {
const [pending, setPending] = useState<PendingResponse['data'] | null>(null);
const [buildings, setBuildings] = useState<Building[]>([]);
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(true);
const [resolvingId, setResolvingId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const loadData = async () => {
setLoading(true);
setError(null);
try {
const [pendingRes, buildingsRes, employeesRes] = await Promise.all([
backendApi.getDomaPendingMappings(),
backendApi.getBuildings(),
backendApi.getEmployees(),
]);
if (!pendingRes.success) {
throw new Error('Ошибка при загрузке ожидающих сопоставлений');
}
setPending(pendingRes.data);
setBuildings(buildingsRes);
setEmployees(employeesRes);
} catch (e: any) {
console.error('[DomaPendingMappings] Ошибка загрузки данных:', e);
setError(e?.message || 'Ошибка загрузки данных');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const buildingOptions = useMemo(
() =>
buildings.map((b) => ({
id: b.id,
label: b.passport?.address || 'Без адреса',
})),
[buildings]
);
const employeeOptions = useMemo(
() =>
employees.map((e) => ({
id: e.id,
label: e.name,
})),
[employees]
);
const handleResolve = async (item: PendingItem, type: 'building' | 'employee', targetId: string) => {
if (!targetId) return;
try {
setResolvingId(item.id);
await backendApi.resolveDomaPendingMapping(item.id, targetId);
window.dispatchEvent(new CustomEvent('mkd-applications-changed'));
await loadData();
} catch (e: any) {
console.error('[DomaPendingMappings] Ошибка при сопоставлении:', e);
setError(e?.message || 'Ошибка при сопоставлении');
} finally {
setResolvingId(null);
}
};
const hasData =
pending && (pending.buildings.length > 0 || pending.employees.length > 0);
return (
<div className="bg-white rounded-2xl shadow-lg border border-slate-200 p-4 md:p-6 space-y-4">
<div className="flex items-center justify-between gap-2 mb-2">
<div>
<h3 className="text-base md:text-lg font-bold text-slate-800">
Ожидающие сопоставления из Doma AI
</h3>
<p className="text-[11px] text-slate-500">
Здесь вы вручную связываете адреса и исполнителей из Doma AI с домами и
сотрудниками вашей базы. После этого синхронизация будет «чистой».
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={loadData}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-xl text-[11px] font-bold border border-slate-200 text-slate-600 hover:bg-slate-50"
>
<RefreshCw className="w-3 h-3" /> Обновить
</button>
{onClose && (
<button
onClick={onClose}
className="text-[11px] text-slate-400 hover:text-slate-600 px-2 py-1 rounded-lg border border-transparent hover:border-slate-200"
>
Закрыть
</button>
)}
</div>
</div>
{loading && (
<div className="flex items-center justify-center py-10 text-slate-500 text-sm">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Загрузка ожидающих сопоставлений...
</div>
)}
{!loading && error && (
<div className="flex items-center gap-2 text-sm text-red-600 bg-red-50 border border-red-100 rounded-xl px-3 py-2">
<AlertTriangle className="w-4 h-4" />
<span>{error}</span>
</div>
)}
{!loading && !error && !hasData && (
<div className="text-center py-8 text-sm text-slate-500">
<p className="font-medium">Нет ожидающих сопоставлений</p>
<p className="text-xs mt-1">
Все адреса и исполнители из Doma AI уже сопоставлены с вашей базой.
</p>
</div>
)}
{!loading && !error && hasData && pending && (
<div className="space-y-6">
{pending.buildings.length > 0 && (
<section>
<h4 className="text-xs font-black text-slate-500 uppercase tracking-[0.2em] mb-2">
Адреса домов
</h4>
<div className="space-y-2">
{pending.buildings.map((item) => (
<div
key={`b-${item.id}`}
className="flex flex-col md:flex-row md:items-center gap-2 border border-slate-200 rounded-xl px-3 py-2 bg-slate-50/60"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">
{item.domaValue}
</p>
<p className="text-[11px] text-slate-400">
из Doma AI {new Date(item.createdAt).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 md:min-w-[260px]">
<select
className="flex-1 text-xs border border-slate-200 rounded-xl px-2 py-1.5 bg-white"
defaultValue=""
onChange={(e) =>
handleResolve(
item,
'building',
e.target.value || ''
)
}
disabled={resolvingId === item.id}
>
<option value="">Выберите дом...</option>
{buildingOptions.map((b) => (
<option key={b.id} value={b.id}>
{b.label}
</option>
))}
</select>
{resolvingId === item.id && (
<Loader2 className="w-4 h-4 text-primary-500 animate-spin" />
)}
</div>
</div>
))}
</div>
</section>
)}
{pending.employees.length > 0 && (
<section>
<h4 className="text-xs font-black text-slate-500 uppercase tracking-[0.2em] mb-2">
Исполнители (сотрудники)
</h4>
<div className="space-y-2">
{pending.employees.map((item) => (
<div
key={`e-${item.id}`}
className="flex flex-col md:flex-row md:items-center gap-2 border border-slate-200 rounded-xl px-3 py-2 bg-slate-50/60"
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">
{item.domaValue}
</p>
<p className="text-[11px] text-slate-400">
из Doma AI {new Date(item.createdAt).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 md:min-w-[260px]">
<select
className="flex-1 text-xs border border-slate-200 rounded-xl px-2 py-1.5 bg-white"
defaultValue=""
onChange={(e) =>
handleResolve(
item,
'employee',
e.target.value || ''
)
}
disabled={resolvingId === item.id}
>
<option value="">Выберите сотрудника...</option>
{employeeOptions.map((e) => (
<option key={e.id} value={e.id}>
{e.label}
</option>
))}
</select>
{resolvingId === item.id && (
<Loader2 className="w-4 h-4 text-primary-500 animate-spin" />
)}
</div>
</div>
))}
</div>
</section>
)}
<p className="text-[11px] text-slate-400 flex items-center gap-1">
<Check className="w-3 h-3 text-emerald-500" />
После сопоставления значение будет сохранено и в следующих синхронизациях
дом/сотрудник будут подставляться автоматически.
</p>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,332 @@
import React, { useState, useEffect, useMemo } from 'react';
import { X, Calendar } from 'lucide-react';
import { DomaApplication, DomaApplicationStatus } from '../../types';
import { backendApi } from '../../services/apiClient';
import { Building, Employee } from '../../types';
type ResidentOption = { key: string; fullName: string; apartment: string; phone?: string };
function residentsFromBuilding(building: Building | null): ResidentOption[] {
if (!building?.accounts?.length) return [];
const list: ResidentOption[] = [];
for (const acc of building.accounts) {
const apt = acc.apartmentNumber || '';
(acc.owners || []).forEach((o) => {
if (o?.fullName) list.push({ key: `o:${acc.id}:${o.fullName}`, fullName: o.fullName, apartment: apt, phone: o.phone });
});
(acc.registered || []).forEach((r) => {
if (r?.fullName) list.push({ key: `r:${acc.id}:${r.id}`, fullName: r.fullName, apartment: apt, phone: r.phone });
});
}
const seen = new Set<string>();
return list.filter((r) => {
const k = `${r.apartment}::${r.fullName}`.toLowerCase();
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}
const STATUS_OPTIONS: { value: DomaApplicationStatus; label: string }[] = [
{ value: 'new', label: 'Новая' },
{ value: 'in_progress', label: 'В работе' },
{ value: 'deferred', label: 'Отложена' },
{ value: 'done', label: 'Выполнена' },
{ value: 'canceled', label: 'Отменена' },
];
interface Props {
isOpen: boolean;
onClose: () => void;
application: DomaApplication;
onSuccess: () => void;
}
export const EditApplicationModal: React.FC<Props> = ({ isOpen, onClose, application, onSuccess }) => {
const [status, setStatus] = useState<DomaApplicationStatus>(application.status || 'new');
const [deadlineAt, setDeadlineAt] = useState('');
const [buildingId, setBuildingId] = useState('');
const [address, setAddress] = useState('');
const [apartment, setApartment] = useState('');
const [description, setDescription] = useState('');
const [contactName, setContactName] = useState('');
const [contactPhone, setContactPhone] = useState('');
const [selectedResidentKey, setSelectedResidentKey] = useState('');
const [executorName, setExecutorName] = useState('');
const [responsibleName, setResponsibleName] = useState('');
const [observersText, setObserversText] = useState('');
const [buildings, setBuildings] = useState<Building[]>([]);
const [selectedBuilding, setSelectedBuilding] = useState<Building | null>(null);
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const residents = useMemo(() => residentsFromBuilding(selectedBuilding), [selectedBuilding]);
useEffect(() => {
if (isOpen) {
setStatus((application.status as DomaApplicationStatus) || 'new');
if (application.deadlineAt) {
const d = new Date(application.deadlineAt);
const pad = (n: number) => String(n).padStart(2, '0');
setDeadlineAt(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`);
} else {
setDeadlineAt('');
}
setAddress(application.address || '');
setApartment(application.apartment || '');
setDescription(application.description || '');
setContactName(application.contactName || application.clientName || '');
setContactPhone(application.contactPhone || '');
setExecutorName(application.executorName || application.performer?.name || '');
setResponsibleName(application.responsibleName || '');
setObserversText(application.observersText || '');
setError(null);
backendApi.getEmployees().then(setEmployees).catch(() => setEmployees([]));
backendApi.getBuildings().then(setBuildings).catch(() => setBuildings([]));
}
}, [isOpen, application]);
useEffect(() => {
if (!isOpen) return;
const bid = application.buildingId || buildings.find(b => (b.passport as any)?.address === application.address)?.id || '';
setBuildingId(bid);
}, [isOpen, application.buildingId, application.address, buildings]);
useEffect(() => {
if (!buildingId) {
setSelectedBuilding(null);
return;
}
if (buildingId !== application.buildingId) {
setSelectedResidentKey('');
setContactName('');
setContactPhone('');
setApartment('');
}
backendApi.getBuilding(buildingId).then((b) => {
setSelectedBuilding(b);
setAddress((b.passport as any)?.address || buildingId);
}).catch(() => setSelectedBuilding(null));
}, [buildingId]);
useEffect(() => {
if (!residents.length || !contactName || selectedResidentKey) return;
const match = residents.find(r => r.fullName === contactName && r.apartment === apartment);
if (match) setSelectedResidentKey(match.key);
}, [residents, contactName, apartment]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!buildingId || !address.trim()) {
setError('Выберите адрес (дом)');
return;
}
if (!description.trim()) {
setError('Опишите проблему');
return;
}
setLoading(true);
try {
await backendApi.updateApplication(application.id, {
status,
deadlineAt: deadlineAt ? new Date(deadlineAt).toISOString() : undefined,
address: address.trim(),
buildingId: buildingId || undefined,
apartment: apartment.trim() || undefined,
description: description.trim(),
contactName: contactName.trim() || undefined,
contactPhone: contactPhone.trim() || undefined,
executorName: executorName.trim() || undefined,
responsibleName: responsibleName.trim() || undefined,
observersText: observersText.trim() || undefined,
changedBy: 'Администратор',
});
window.dispatchEvent(new CustomEvent('mkd-applications-changed'));
onSuccess();
onClose();
} catch (err: any) {
setError(err.message || 'Ошибка при сохранении');
} finally {
setLoading(false);
}
};
const handleClose = () => {
if (!loading) onClose();
};
return (
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={handleClose}>
<div className="bg-white rounded-3xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl animate-scale-in" onClick={e => e.stopPropagation()}>
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-center rounded-t-3xl z-10">
<h3 className="text-lg font-black text-slate-800">Редактировать заявку {application.number}</h3>
<button type="button" onClick={handleClose} className="p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600 rounded-xl transition-colors disabled:opacity-50">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 text-sm px-4 py-3 rounded-xl">
{error}
</div>
)}
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Статус</label>
<select
value={status}
onChange={e => setStatus(e.target.value as DomaApplicationStatus)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{STATUS_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Выполнить до</label>
<div className="flex items-center gap-2">
<input
type="datetime-local"
value={deadlineAt}
onChange={e => setDeadlineAt(e.target.value)}
className="flex-1 px-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
<Calendar className="w-5 h-5 text-slate-400 shrink-0" />
</div>
</div>
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Адрес (дом) <span className="text-red-500">*</span></label>
<select
value={buildingId}
onChange={e => setBuildingId(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">Выбрать дом</option>
{buildings.map(b => (
<option key={b.id} value={b.id}>{(b.passport as any)?.address || b.id}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Заявитель (житель/собственник)</label>
<select
value={selectedResidentKey}
onChange={e => {
const key = e.target.value;
setSelectedResidentKey(key);
const r = residents.find(x => x.key === key);
if (r) {
setContactName(r.fullName);
setContactPhone(r.phone || '');
setApartment(r.apartment);
}
}}
className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">{buildingId ? 'Выбрать жителя или собственника' : 'Сначала выберите адрес (дом)'}</option>
{residents.map(r => (
<option key={r.key} value={r.key}>{r.fullName}, кв. {r.apartment}{r.phone ? `${r.phone}` : ''}</option>
))}
</select>
{(selectedResidentKey || contactName) && (
<p className="text-xs text-slate-500 mt-1">
{contactName}{contactPhone ? ` · ${contactPhone}` : ''}{apartment ? ` · кв. ${apartment}` : ''}
</p>
)}
</div>
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Квартира</label>
<input
type="text"
value={apartment}
onChange={e => setApartment(e.target.value)}
placeholder="Номер квартиры"
className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Телефон заявителя</label>
<input
type="tel"
value={contactPhone}
onChange={e => setContactPhone(e.target.value)}
placeholder="+7 900 123-45-67"
className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Проблема <span className="text-red-500">*</span></label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Опишите проблему"
rows={4}
className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-y"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Исполнитель</label>
<select
value={executorName}
onChange={e => setExecutorName(e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">Выбрать</option>
{employees.map(emp => (
<option key={emp.id} value={emp.name}>{emp.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Ответственный</label>
<select
value={responsibleName}
onChange={e => setResponsibleName(e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">Выбрать</option>
{employees.map(emp => (
<option key={emp.id} value={emp.name}>{emp.name}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Наблюдатели</label>
<input
type="text"
value={observersText}
onChange={e => setObserversText(e.target.value)}
placeholder="ФИО через запятую"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div className="flex gap-3 pt-4 border-t border-slate-200">
<button type="button" onClick={handleClose} disabled={loading} className="flex-1 px-4 py-3 bg-slate-100 text-slate-700 font-black text-sm rounded-xl hover:bg-slate-200 transition-colors disabled:opacity-50">
Отмена
</button>
<button type="submit" disabled={loading} className="flex-1 px-4 py-3 bg-primary-600 text-white font-black text-sm rounded-xl hover:bg-primary-700 disabled:opacity-50 transition-colors">
{loading ? 'Сохранение…' : 'Сохранить'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@@ -0,0 +1,173 @@
import React, { useState, useEffect } from 'react';
import { X, Calendar, MapPin, Zap, FileText, User } from 'lucide-react';
import { Outage } from '../../types';
import { backendApi } from '../../services/apiClient';
const WORK_TYPE_LABELS: Record<string, string> = {
absent: 'Отсутствует',
planned: 'Плановый',
emergency: 'Аварийный',
};
interface Props {
outageId: number;
onClose: () => void;
onUpdated?: () => void;
}
export const OutageCardDetail: React.FC<Props> = ({ outageId, onClose, onUpdated }) => {
const [outage, setOutage] = useState<Outage | null>(null);
const [loading, setLoading] = useState(true);
const loadOutage = () => {
backendApi.getOutage(outageId).then(setOutage).catch(() => setOutage(null));
};
useEffect(() => {
setLoading(true);
loadOutage();
setLoading(false);
}, [outageId]);
const handleSetInactive = async () => {
if (!outage) return;
try {
await backendApi.updateOutage(outageId, { active: false });
loadOutage();
onUpdated?.();
} catch (err) {
console.error(err);
}
};
if (loading || !outage) {
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in">
<div className="bg-white rounded-3xl w-full max-w-2xl p-12 text-center text-slate-500 shadow-2xl">
{loading ? 'Загрузка…' : 'Отключение не найдено'}
</div>
</div>
);
}
const formatDate = (s: string) => {
const d = new Date(s);
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const workTypeLabel = outage.workType ? (WORK_TYPE_LABELS[outage.workType] || outage.workType) : '—';
const classifierText = [outage.category, outage.problemDetail].filter(Boolean).join(' » ') || outage.type || '—';
const address = outage.buildingAddress || outage.buildingId;
const endDate = outage.endAt ? new Date(outage.endAt) : null;
const now = new Date();
const remainingMs = endDate && endDate > now ? endDate.getTime() - now.getTime() : null;
const remainingStr = remainingMs != null
? `${String(Math.floor(remainingMs / 3600000)).padStart(2, '0')}:${String(Math.floor((remainingMs % 3600000) / 60000)).padStart(2, '0')}`
: null;
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm overflow-y-auto animate-fade-in">
<div className="bg-white rounded-3xl w-full max-w-2xl max-h-[95vh] overflow-y-auto shadow-2xl my-8 animate-scale-in">
{/* Header */}
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 rounded-t-3xl z-10 flex justify-between items-start">
<div>
<h1 className="text-lg font-black text-slate-800">Отключение {outage.id}</h1>
<p className="text-xs text-slate-500 mt-1">
Дата создания {formatDate(outage.createdAt)}, автор {outage.authorName || 'Администратор'} (вы)
</p>
</div>
<div className="flex items-center gap-2">
{outage.active && (
<span className="inline-flex items-center gap-1 text-[10px] font-black uppercase tracking-wider text-rose-700 bg-rose-100 px-3 py-1.5 rounded-xl">
<Zap className="w-3.5 h-3.5" /> Актуально
</span>
)}
{!outage.active && (
<span className="inline-flex items-center gap-1 text-[10px] font-black uppercase tracking-wider text-slate-600 bg-slate-200 px-3 py-1.5 rounded-xl">
Завершено
</span>
)}
<button type="button" onClick={onClose} className="p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600 rounded-xl transition-colors">
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="px-6 py-4 space-y-4">
{/* Адрес */}
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Адрес</label>
<p className="text-sm font-medium text-slate-800 flex items-center gap-1">
<MapPin className="w-4 h-4 text-slate-400 shrink-0" />
<span className="text-primary-600 underline cursor-pointer hover:no-underline">{address}</span>
</p>
</div>
{/* Дата проведения работ */}
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Дата проведения работ</label>
<p className="text-sm font-medium text-slate-800 flex items-center gap-1 flex-wrap">
<Calendar className="w-4 h-4 text-slate-400 shrink-0" />
с {formatDate(outage.startAt)}
{outage.endAt && (
<>
{' '}до {formatDate(outage.endAt)}
{remainingStr && outage.active && (
<span className="text-orange-600 font-bold text-xs">(осталось {remainingStr})</span>
)}
</>
)}
</p>
</div>
{/* Классификатор */}
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Классификатор</label>
<p className="text-sm text-slate-800">{classifierText}</p>
</div>
{/* Тип работ */}
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Тип работ</label>
<p className="text-sm text-slate-800">{workTypeLabel}</p>
</div>
{/* Что случилось */}
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Что случилось</label>
<p className="text-sm text-slate-800 flex items-start gap-1">
<FileText className="w-4 h-4 text-slate-400 shrink-0 mt-0.5" />
{outage.description || '—'}
</p>
</div>
{/* Что сказать жителю */}
<div>
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-wider mb-1">Что сказать жителю</label>
<p className="text-sm text-slate-800 flex items-start gap-1">
<User className="w-4 h-4 text-slate-400 shrink-0 mt-0.5" />
{outage.residentMessage || '—'}
</p>
</div>
</div>
{/* Действия */}
<div className="sticky bottom-0 bg-white border-t border-slate-200 px-6 py-4 rounded-b-3xl flex flex-wrap gap-2">
{outage.active && (
<button
type="button"
onClick={handleSetInactive}
className="px-4 py-2.5 bg-slate-100 text-slate-700 rounded-xl font-black text-xs uppercase tracking-wider hover:bg-slate-200 transition-colors"
>
Завершить отключение
</button>
)}
<button type="button" onClick={onClose} className="px-4 py-2.5 bg-primary-600 text-white rounded-xl font-black text-xs uppercase tracking-wider hover:bg-primary-700 transition-colors">
Закрыть
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,203 @@
import React, { useState, useEffect } from 'react';
import { History, Zap, Building2, Calendar, X, Plus } from 'lucide-react';
import { Outage, Building } from '../../types';
import { backendApi } from '../../services/apiClient';
import { CreateOutageCard } from './CreateOutageCard';
import { OutageCardDetail } from './OutageCardDetail';
interface Props {
/** В режиме "по дому" передать buildingId — показываем только по этому дому и кнопка История по этому дому */
buildingId?: string;
/** Компактный вид для блока в сводке дома */
compact?: boolean;
}
export const OutagesJournal: React.FC<Props> = ({ buildingId, compact }) => {
const [activeOutages, setActiveOutages] = useState<Outage[]>([]);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyOutages, setHistoryOutages] = useState<Outage[]>([]);
const [historyBuildingFilter, setHistoryBuildingFilter] = useState<string>(buildingId || '');
const [buildings, setBuildings] = useState<Building[]>([]);
const [loading, setLoading] = useState(true);
const [historyLoading, setHistoryLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [openOutageId, setOpenOutageId] = useState<number | null>(null);
const fetchActive = () => {
setLoading(true);
backendApi
.getOutages(buildingId ? { buildingId, active: true } : { active: true })
.then(setActiveOutages)
.catch(() => setActiveOutages([]))
.finally(() => setLoading(false));
};
useEffect(() => {
fetchActive();
}, [buildingId]);
useEffect(() => {
if (historyOpen) {
backendApi.getBuildings().then(setBuildings).catch(() => setBuildings([]));
if (!buildingId) setHistoryBuildingFilter('');
}
}, [historyOpen, buildingId]);
const loadHistory = () => {
setHistoryLoading(true);
const filter = buildingId || historyBuildingFilter;
const params = filter ? { buildingId: filter } : undefined;
backendApi
.getOutages(params)
.then(setHistoryOutages)
.catch(() => setHistoryOutages([]))
.finally(() => setHistoryLoading(false));
};
useEffect(() => {
if (historyOpen) loadHistory();
}, [historyOpen, historyBuildingFilter, buildingId]);
const formatDate = (s: string) => {
const d = new Date(s);
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const title = buildingId ? 'Журнал отключений' : 'Журнал отключений';
const subtitle = buildingId ? 'По этому дому' : 'Активные по всем домам';
return (
<div className="space-y-4 animate-fade-in">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h4 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em] px-1">{title}</h4>
<p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCreateOpen(true)}
className="inline-flex items-center gap-2 px-3 py-2 text-[10px] font-black uppercase tracking-wider text-white bg-primary-600 hover:bg-primary-700 rounded-xl transition-colors shadow-sm"
>
<Plus className="w-3.5 h-3.5" /> Новая запись
</button>
<button
type="button"
onClick={() => setHistoryOpen(true)}
className="inline-flex items-center gap-2 px-3 py-2 text-xs font-medium text-slate-600 bg-slate-100 hover:bg-slate-200 rounded-xl transition-colors"
>
<History className="w-3.5 h-3.5" /> История
</button>
</div>
</div>
{loading ? (
<p className="text-sm text-slate-400 py-6">Загрузка</p>
) : activeOutages.length === 0 ? (
<p className="text-sm text-slate-400 py-6 italic">Нет активных отключений</p>
) : (
<ul className={`space-y-2 ${compact ? '' : 'space-y-3'}`}>
{activeOutages.map((o) => (
<li
key={o.id}
onClick={() => setOpenOutageId(o.id)}
className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm flex flex-col sm:flex-row sm:items-center gap-3 cursor-pointer hover:border-primary-300 hover:shadow-md transition-all"
>
<div className="flex-1 min-w-0">
{!buildingId && o.buildingAddress && (
<p className="text-xs font-bold text-slate-500 flex items-center gap-1">
<Building2 className="w-3.5 h-3.5" /> {o.buildingAddress}
</p>
)}
<p className="font-medium text-slate-800 mt-0.5">{o.category || o.type || 'Отключение'}</p>
{o.description && <p className="text-sm text-slate-600 mt-1 line-clamp-2">{o.description}</p>}
<p className="text-xs text-slate-400 mt-1 flex items-center gap-1">
<Calendar className="w-3 h-3" /> с {formatDate(o.startAt)}
{o.endAt && ` до ${formatDate(o.endAt)}`}
</p>
</div>
<span className="inline-flex items-center gap-1 text-[10px] font-black uppercase tracking-wider text-rose-700 bg-rose-100 px-2 py-1 rounded-xl shrink-0">
<Zap className="w-3 h-3" /> Активно
</span>
</li>
))}
</ul>
)}
{createOpen && (
<CreateOutageCard
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={() => { fetchActive(); setCreateOpen(false); }}
buildingId={buildingId}
/>
)}
{openOutageId !== null && (
<OutageCardDetail
outageId={openOutageId}
onClose={() => setOpenOutageId(null)}
onUpdated={fetchActive}
/>
)}
{/* Модалка История */}
{historyOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={() => setHistoryOpen(false)}>
<div className="bg-white rounded-3xl w-full max-w-2xl max-h-[85vh] overflow-hidden shadow-2xl animate-scale-in flex flex-col" onClick={e => e.stopPropagation()}>
<div className="p-4 border-b border-slate-200 flex justify-between items-center rounded-t-3xl">
<h3 className="text-lg font-black text-slate-800">История отключений</h3>
<button type="button" onClick={() => setHistoryOpen(false)} className="p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600 rounded-xl transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{!buildingId && (
<div className="px-4 py-3 border-b border-slate-100">
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Дом</label>
<select
value={historyBuildingFilter}
onChange={e => setHistoryBuildingFilter(e.target.value)}
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">Все дома</option>
{buildings.map(b => (
<option key={b.id} value={b.id}>{(b.passport as any)?.address || b.id}</option>
))}
</select>
</div>
)}
<div className="flex-1 overflow-y-auto p-4">
{historyLoading ? (
<p className="text-sm text-slate-400 py-6">Загрузка</p>
) : historyOutages.length === 0 ? (
<p className="text-sm text-slate-400 py-6 italic">Нет записей</p>
) : (
<ul className="space-y-3">
{historyOutages.map((o) => (
<li
key={o.id}
onClick={() => { setHistoryOpen(false); setOpenOutageId(o.id); }}
className="bg-slate-50 rounded-xl p-4 border border-slate-100 cursor-pointer hover:border-primary-200 hover:bg-slate-50/80 transition-colors"
>
{!buildingId && o.buildingAddress && (
<p className="text-xs font-bold text-slate-500 flex items-center gap-1">
<Building2 className="w-3.5 h-3.5" /> {o.buildingAddress}
</p>
)}
<p className="font-medium text-slate-800 mt-0.5">{o.category || o.type || 'Отключение'}</p>
{o.description && <p className="text-sm text-slate-600 mt-1 line-clamp-2">{o.description}</p>}
<p className="text-xs text-slate-400 mt-1">
{formatDate(o.startAt)}
{o.endAt ? `${formatDate(o.endAt)}` : ''}
{o.active && <span className="ml-2 text-rose-600 font-black uppercase text-[10px]">Активно</span>}
</p>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,343 @@
import React, { useState, useEffect } from 'react';
import { Star, MessageCircle, ThumbsUp, ThumbsDown, Award, Search, UserCheck, TrendingUp, AlertCircle, Clock, Inbox } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
interface OverallStats {
total: number;
completed: number;
overdue: number;
inProgress: number;
completionRate: number;
overdueRate: number;
}
interface EmployeeStats {
employeeName: string;
districtName: string | null;
totalAssigned: number;
totalCompleted: number;
totalOverdue: number;
performanceScore: number;
}
interface DistrictStats {
districtId: string;
districtName: string;
managerName: string;
totalApplications: number;
totalCompleted: number;
totalOverdue: number;
completionRate: number;
overdueRate: number;
averageScore: number;
}
export const QualityControl: React.FC = () => {
const [overallStats, setOverallStats] = useState<OverallStats | null>(null);
const [employeeStats, setEmployeeStats] = useState<EmployeeStats[]>([]);
const [districtStats, setDistrictStats] = useState<DistrictStats[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchStats = async () => {
setLoading(true);
setError(null);
try {
const [overallRes, employeesRes, districtsRes] = await Promise.all([
backendApi.getOverallPerformance(),
backendApi.getEmployeePerformance(),
backendApi.getDistrictPerformance(),
]);
if (overallRes.success) {
setOverallStats(overallRes.data);
}
if (employeesRes.success) {
setEmployeeStats(employeesRes.data);
}
if (districtsRes.success) {
setDistrictStats(districtsRes.data);
}
} catch (err) {
console.error('[QualityControl] Ошибка загрузки статистики:', err);
setError('Не удалось загрузить статистику производительности');
} finally {
setLoading(false);
}
};
fetchStats();
}, []);
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-20 animate-pulse">
<TrendingUp className="w-10 h-10 text-primary-400 animate-spin mb-4" />
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Загрузка статистики...</p>
</div>
);
}
if (error) {
return (
<div className="text-center py-20">
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
<p className="text-sm font-bold text-red-600">{error}</p>
</div>
);
}
const bestEmployee = employeeStats.length > 0
? employeeStats.reduce((best, current) =>
current.performanceScore > best.performanceScore ? current : best
)
: null;
// Рассчитываем общий CSAT на основе completionRate и overdueRate
const csatScore = overallStats
? Math.max(1, Math.min(5, (overallStats.completionRate / 100) * 5 - (overallStats.overdueRate / 100) * 2))
: 0;
return (
<div className="space-y-6 animate-fade-in">
{/* Satisfaction Summary */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-2 bg-white p-8 rounded-[2.5rem] border border-slate-200 shadow-sm flex flex-col justify-between">
<div className="flex justify-between items-start mb-8">
<div>
<h3 className="text-2xl font-black text-slate-800">Качество сервиса</h3>
<p className="text-xs text-slate-500 font-medium mt-1 uppercase tracking-widest">Индекс производительности</p>
</div>
<div className="p-4 bg-amber-50 rounded-2xl text-amber-500">
<Star className="w-8 h-8 fill-current"/>
</div>
</div>
<div className="flex items-end gap-12">
<div>
<p className="text-6xl font-black text-slate-800">{csatScore.toFixed(1)}</p>
<div className="flex gap-1 mt-2">
{[1,2,3,4,5].map(i => (
<Star
key={i}
className={`w-4 h-4 ${i <= Math.round(csatScore) ? 'fill-amber-400 text-amber-400' : 'text-slate-200'}`}
/>
))}
</div>
</div>
<div className="flex-1 space-y-3">
{overallStats && (
<>
<RatingBar
label="Выполнение заявок"
value={overallStats.completionRate}
/>
<RatingBar
label="Просроченные заявки"
value={100 - overallStats.overdueRate}
inverse
/>
<RatingBar
label="В работе"
value={(overallStats.inProgress / overallStats.total) * 100}
/>
</>
)}
</div>
</div>
</div>
<div className="bg-gradient-to-br from-violet-600 to-indigo-800 p-6 rounded-[2.5rem] text-white shadow-xl relative overflow-hidden">
<Award className="absolute -bottom-4 -right-4 w-32 h-32 opacity-10 rotate-12" />
<h4 className="font-black text-xs uppercase tracking-widest mb-6 opacity-70">Лучший мастер месяца</h4>
{bestEmployee ? (
<div className="text-center">
<div className="w-20 h-20 rounded-full bg-white/20 border-4 border-white/20 mx-auto mb-4 flex items-center justify-center text-3xl font-black">
{bestEmployee.employeeName.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase()}
</div>
<p className="text-lg font-black leading-none">{bestEmployee.employeeName}</p>
{bestEmployee.districtName && (
<p className="text-[10px] font-bold uppercase text-indigo-200 mt-2">{bestEmployee.districtName}</p>
)}
<div className="mt-6 flex justify-center gap-4">
<div className="text-center">
<p className="text-xl font-black">{bestEmployee.totalCompleted}</p>
<p className="text-[8px] font-bold uppercase opacity-50">Выполнено</p>
</div>
<div className="w-px h-8 bg-white/10" />
<div className="text-center">
<p className="text-xl font-black">{bestEmployee.performanceScore.toFixed(1)}</p>
<p className="text-[8px] font-bold uppercase opacity-50">Рейтинг</p>
</div>
</div>
</div>
) : (
<div className="text-center text-indigo-200">
<p className="text-sm">Нет данных</p>
</div>
)}
</div>
</div>
{/* Статистика по участкам */}
{districtStats.length > 0 && (
<div className="space-y-4">
<h4 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em] px-1">Статистика по участкам</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{districtStats.map((district) => (
<DistrictCard key={district.districtId} district={district} />
))}
</div>
</div>
)}
{/* Топ сотрудников */}
{employeeStats.length > 0 && (
<div className="space-y-4">
<h4 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em] px-1">Рейтинг сотрудников</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{employeeStats
.sort((a, b) => b.performanceScore - a.performanceScore)
.slice(0, 6)
.map((employee, index) => (
<EmployeeCard key={employee.employeeName} employee={employee} rank={index + 1} />
))}
</div>
</div>
)}
{/* Общая статистика */}
{overallStats && (
<div className="bg-white p-6 rounded-[2.5rem] border border-slate-200 shadow-sm">
<h4 className="font-black text-slate-800 text-sm mb-4">Общая статистика</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
label="Всего заявок"
value={overallStats.total}
icon={<Inbox className="w-5 h-5" />}
/>
<StatCard
label="Выполнено"
value={overallStats.completed}
icon={<TrendingUp className="w-5 h-5 text-emerald-500" />}
color="emerald"
/>
<StatCard
label="Просрочено"
value={overallStats.overdue}
icon={<AlertCircle className="w-5 h-5 text-red-500" />}
color="red"
/>
<StatCard
label="В работе"
value={overallStats.inProgress}
icon={<Clock className="w-5 h-5 text-blue-500" />}
color="blue"
/>
</div>
</div>
)}
</div>
);
};
const RatingBar = ({ label, value, inverse = false }: { label: string; value: number; inverse?: boolean }) => {
const displayValue = Math.max(0, Math.min(100, value));
const color = inverse ? (displayValue < 50 ? 'bg-red-500' : displayValue < 80 ? 'bg-amber-500' : 'bg-emerald-500') : 'bg-primary-500';
return (
<div className="space-y-1">
<div className="flex justify-between text-[10px] font-black uppercase text-slate-400">
<span>{label}</span>
<span className="text-slate-700">{displayValue.toFixed(1)}%</span>
</div>
<div className="h-1 w-full bg-slate-100 rounded-full">
<div className={`h-full ${color} rounded-full transition-all`} style={{ width: `${displayValue}%` }} />
</div>
</div>
);
};
const DistrictCard = ({ district }: { district: DistrictStats }) => (
<div className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm">
<div className="flex justify-between items-start mb-3">
<div>
<h5 className="font-black text-slate-800 text-sm leading-none">{district.districtName}</h5>
<p className="text-[10px] text-slate-400 font-bold mt-1 uppercase tracking-tight">{district.managerName}</p>
</div>
<div className="text-right">
<p className="text-2xl font-black text-primary-600">{district.averageScore.toFixed(1)}</p>
<p className="text-[8px] font-bold uppercase text-slate-400">Рейтинг</p>
</div>
</div>
<div className="space-y-2 mt-4">
<div className="flex justify-between text-[10px] font-bold text-slate-600">
<span>Выполнено:</span>
<span>{district.totalCompleted} / {district.totalApplications}</span>
</div>
<div className="flex justify-between text-[10px] font-bold text-slate-600">
<span>Просрочено:</span>
<span className={district.totalOverdue > 0 ? 'text-red-600' : ''}>{district.totalOverdue}</span>
</div>
<div className="h-1 w-full bg-slate-100 rounded-full">
<div
className="h-full bg-emerald-500 rounded-full"
style={{ width: `${district.completionRate}%` }}
/>
</div>
</div>
</div>
);
const EmployeeCard = ({ employee, rank }: { employee: EmployeeStats; rank: number }) => (
<div className="bg-white p-5 rounded-[2rem] border border-slate-200 shadow-sm flex gap-4">
<div className={`w-12 h-12 rounded-2xl flex-shrink-0 flex items-center justify-center font-black text-white ${
rank === 1 ? 'bg-amber-500' : rank === 2 ? 'bg-slate-400' : rank === 3 ? 'bg-orange-500' : 'bg-primary-500'
}`}>
{rank}
</div>
<div className="flex-1">
<div className="flex justify-between items-start mb-1">
<div>
<h5 className="font-black text-slate-800 text-sm leading-none">{employee.employeeName}</h5>
{employee.districtName && (
<p className="text-[10px] text-slate-400 font-bold mt-1 uppercase tracking-tight">{employee.districtName}</p>
)}
</div>
<div className="text-right">
<p className="text-lg font-black text-primary-600">{employee.performanceScore.toFixed(1)}</p>
<p className="text-[8px] font-bold uppercase text-slate-400">Рейтинг</p>
</div>
</div>
<div className="flex gap-4 mt-2 text-[10px] font-bold text-slate-600">
<span>Выполнено: <span className="text-emerald-600">{employee.totalCompleted}</span></span>
<span>Просрочено: <span className={employee.totalOverdue > 0 ? 'text-red-600' : ''}>{employee.totalOverdue}</span></span>
</div>
</div>
</div>
);
const StatCard = ({ label, value, icon, color = 'primary' }: {
label: string;
value: number;
icon: React.ReactNode;
color?: 'primary' | 'emerald' | 'red' | 'blue';
}) => {
const colorClasses = {
primary: 'text-primary-500',
emerald: 'text-emerald-500',
red: 'text-red-500',
blue: 'text-blue-500',
};
return (
<div className="text-center">
<div className={`inline-flex p-3 rounded-2xl mb-2 ${colorClasses[color].replace('text-', 'bg-').replace('-500', '-50')}`}>
{icon}
</div>
<p className="text-2xl font-black text-slate-800">{value}</p>
<p className="text-[10px] font-bold uppercase text-slate-400 mt-1">{label}</p>
</div>
);
};

View File

@@ -0,0 +1,198 @@
import React, { useState } from 'react';
import { X, Send, Calendar } from 'lucide-react';
import { DomaApplicationStatus } from '../../types';
interface Props {
isOpen: boolean;
onClose: () => void;
application: {
id: number;
number: string;
address: string;
currentStatus: DomaApplicationStatus;
};
newStatus: DomaApplicationStatus;
onConfirm: (comment: string, deferredUntil?: string) => Promise<void>;
}
export const StatusChangeModal: React.FC<Props> = ({
isOpen,
onClose,
application,
newStatus,
onConfirm,
}) => {
const [comment, setComment] = useState('');
const [deferredUntil, setDeferredUntil] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!isOpen) return null;
const statusLabels: Record<DomaApplicationStatus, string> = {
new: 'Новая',
in_progress: 'В работу',
deferred: 'Отложить',
done: 'Завершить',
canceled: 'Отменить',
};
const statusMessages: Record<DomaApplicationStatus, string> = {
new: 'Укажите причину возврата заявки в статус "Новая"',
in_progress: 'Укажите комментарий для жителя о начале работы',
deferred: 'Укажите причину отложения и новую дату переноса заявки',
done: 'Укажите комментарий для жителя о выполнении работы',
canceled: 'Укажите причину отмены заявки',
};
const isReasonOnly = newStatus === 'canceled' || newStatus === 'deferred';
const commentLabel = isReasonOnly ? (newStatus === 'canceled' ? 'Причина отмены' : 'Причина отложения') : 'Комментарий для жителя';
const requiresDate = newStatus === 'deferred';
const requiresComment = true;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (requiresComment && !comment.trim()) {
setError('Комментарий обязателен для заполнения');
return;
}
if (requiresDate && !deferredUntil) {
setError('Укажите дату отложения заявки');
return;
}
setLoading(true);
try {
await onConfirm(comment.trim(), deferredUntil || undefined);
// Сброс формы после успешного подтверждения
setComment('');
setDeferredUntil('');
onClose();
} catch (err: any) {
setError(err.message || 'Ошибка при обновлении статуса заявки');
} finally {
setLoading(false);
}
};
const handleClose = () => {
if (!loading) {
setComment('');
setDeferredUntil('');
setError(null);
onClose();
}
};
// Минимальная дата - сегодня
const minDate = new Date().toISOString().split('T')[0];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in">
<div className="bg-white rounded-3xl shadow-2xl max-w-md w-full max-h-[90vh] overflow-y-auto animate-scale-in">
{/* Header */}
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between rounded-t-3xl">
<div>
<h2 className="text-lg font-black text-slate-800">
{statusLabels[newStatus]} заявку
</h2>
<p className="text-xs text-slate-500 mt-1">
{application.number} {application.address}
</p>
</div>
<button
onClick={handleClose}
disabled={loading}
className="p-2 hover:bg-slate-100 rounded-xl transition-colors disabled:opacity-50"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
{/* Content */}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div className="text-sm text-slate-600 bg-slate-50 p-3 rounded-xl">
{statusMessages[newStatus]}
</div>
{/* Причина / Комментарий */}
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">
{commentLabel} {requiresComment && <span className="text-red-500">*</span>}
</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder={isReasonOnly ? 'Например: вопрос решил житель самостоятельно' : 'Введите комментарий...'}
required={requiresComment}
disabled={loading}
rows={4}
className="w-full px-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-none disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
{/* Дата отложения */}
{requiresDate && (
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">
Дата отложения <span className="text-red-500">*</span>
</label>
<div className="relative">
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="date"
value={deferredUntil}
onChange={(e) => setDeferredUntil(e.target.value)}
min={minDate}
required
disabled={loading}
className="w-full pl-11 pr-4 py-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
</div>
)}
{/* Ошибка */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 text-sm px-4 py-3 rounded-xl">
{error}
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={handleClose}
disabled={loading}
className="flex-1 px-4 py-3 bg-slate-100 text-slate-700 font-black text-sm rounded-xl hover:bg-slate-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Отмена
</button>
<button
type="submit"
disabled={loading || (requiresComment && !comment.trim()) || (requiresDate && !deferredUntil)}
className="flex-1 px-4 py-3 bg-primary-600 text-white font-black text-sm rounded-xl hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loading ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Сохранение...
</>
) : (
<>
<Send className="w-4 h-4" />
Подтвердить
</>
)}
</button>
</div>
</form>
</div>
</div>
);
};