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

333 lines
15 KiB
TypeScript
Executable File
Raw 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, 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>
);
};