Files
mkd/components/applications/ApplicationCardDetail.tsx

452 lines
20 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
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>
);
};