Initial commit MKD fixes
This commit is contained in:
196
components/applications/AppSummary.tsx
Executable file
196
components/applications/AppSummary.tsx
Executable 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>
|
||||
);
|
||||
451
components/applications/ApplicationCardDetail.tsx
Executable file
451
components/applications/ApplicationCardDetail.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
147
components/applications/ApplicationsRegistry.tsx
Executable file
147
components/applications/ApplicationsRegistry.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
490
components/applications/CreateApplicationCard.tsx
Executable file
490
components/applications/CreateApplicationCard.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
365
components/applications/CreateOutageCard.tsx
Executable file
365
components/applications/CreateOutageCard.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
76
components/applications/DispatcherControl.tsx
Executable file
76
components/applications/DispatcherControl.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
260
components/applications/DomaPendingMappings.tsx
Executable file
260
components/applications/DomaPendingMappings.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
|
||||
332
components/applications/EditApplicationModal.tsx
Executable file
332
components/applications/EditApplicationModal.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
173
components/applications/OutageCardDetail.tsx
Executable file
173
components/applications/OutageCardDetail.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
203
components/applications/OutagesJournal.tsx
Executable file
203
components/applications/OutagesJournal.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
343
components/applications/QualityControl.tsx
Executable file
343
components/applications/QualityControl.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
198
components/applications/StatusChangeModal.tsx
Executable file
198
components/applications/StatusChangeModal.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user