Files
mkd/components/applications/ApplicationCardDetail.tsx
2026-02-04 00:17:04 +05:00

452 lines
20 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};