452 lines
20 KiB
TypeScript
Executable File
452 lines
20 KiB
TypeScript
Executable File
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>
|
||
);
|
||
};
|