Initial commit MKD fixes
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user