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

491 lines
24 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, useMemo } from 'react';
import { X, Paperclip, HelpCircle, Calendar } from 'lucide-react';
import { CreateApplicationPayload } 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 SOURCE_CHANNELS = [
{ value: '', label: 'Выбрать' },
{ value: 'call', label: 'Звонок' },
{ value: 'website', label: 'Заявка с сайта' },
{ value: 'reception', label: 'Личный приём' },
{ value: 'mobile', label: 'Мобильное приложение' },
{ value: 'other', label: 'Другое' },
];
const WORK_TYPES = [
{ value: '', label: 'Выбрать' },
{ value: 'plumbing', label: 'Сантехника' },
{ value: 'electrical', label: 'Электрика' },
{ value: 'heating', label: 'Отопление' },
{ value: 'elevator', label: 'Лифт' },
{ value: 'common', label: 'Общедомовое имущество' },
{ value: 'other', label: 'Другое' },
];
const PROBLEM_DETAIL_OPTIONS = [
{ value: '', label: 'Выбрать' },
{ value: 'leak', label: 'Протечка' },
{ value: 'no_water', label: 'Нет воды' },
{ value: 'no_heat', label: 'Нет отопления' },
{ value: 'no_power', label: 'Нет электричества' },
{ value: 'blockage', label: 'Засор' },
{ value: 'other', label: 'Другое' },
];
const MAX_DESCRIPTION_LENGTH = 700;
interface Props {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export const CreateApplicationCard: React.FC<Props> = ({ isOpen, onClose, onSuccess }) => {
const [buildingId, setBuildingId] = useState('');
const [address, setAddress] = useState('');
const [apartment, setApartment] = useState('');
const [sourceChannel, setSourceChannel] = useState('');
const [isFromResident, setIsFromResident] = useState(true);
const [contactPhone, setContactPhone] = useState('');
const [contactName, setContactName] = useState('');
const [selectedResidentKey, setSelectedResidentKey] = useState('');
const [description, setDescription] = useState('');
const [placeIncident, setPlaceIncident] = useState('');
const [workType, setWorkType] = useState('');
const [problemDetail, setProblemDetail] = useState('');
const [isEmergency, setIsEmergency] = useState(false);
const [isPaid, setIsPaid] = useState(false);
const [isWarranty, setIsWarranty] = useState(false);
const [deadlineAt, setDeadlineAt] = useState('');
const [executorName, setExecutorName] = useState('');
const [responsibleName, setResponsibleName] = useState('');
const [observersText, setObserversText] = useState('');
const [showInApp, setShowInApp] = useState(false);
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) {
backendApi.getBuildings().then(setBuildings).catch(() => setBuildings([]));
backendApi.getEmployees().then(setEmployees).catch(() => setEmployees([]));
const defaultDeadline = new Date();
defaultDeadline.setDate(defaultDeadline.getDate() + 8);
setDeadlineAt(defaultDeadline.toISOString().slice(0, 10));
setBuildingId('');
setSelectedBuilding(null);
setAddress('');
setPlaceIncident('');
setSelectedResidentKey('');
setContactName('');
setContactPhone('');
setApartment('');
}
}, [isOpen]);
useEffect(() => {
if (!buildingId) {
setSelectedBuilding(null);
setAddress('');
setPlaceIncident('');
setSelectedResidentKey('');
setContactName('');
setContactPhone('');
setApartment('');
return;
}
backendApi.getBuilding(buildingId).then((b) => {
setSelectedBuilding(b);
setAddress((b.passport as any)?.address || buildingId);
setPlaceIncident(buildingId);
}).catch(() => {
setSelectedBuilding(null);
setAddress('');
setPlaceIncident('');
});
setSelectedResidentKey('');
setContactName('');
setContactPhone('');
setApartment('');
}, [buildingId]);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!buildingId || !address.trim()) {
setError('Выберите адрес (дом)');
return;
}
if (isFromResident && !selectedResidentKey) {
setError('Выберите заявителя (жителя/собственника)');
return;
}
if (!description.trim()) {
setError('Опишите проблему');
return;
}
if (description.length > MAX_DESCRIPTION_LENGTH) {
setError(`Проблема не более ${MAX_DESCRIPTION_LENGTH} символов`);
return;
}
setLoading(true);
try {
const placeBuilding = buildings.find(b => b.id === placeIncident) || selectedBuilding;
const payload: CreateApplicationPayload = {
address: address.trim(),
description: description.trim(),
apartment: apartment.trim() || undefined,
sourceChannel: sourceChannel || undefined,
isFromResident,
contactPhone: contactPhone.trim() || undefined,
contactName: contactName.trim() || undefined,
placeIncident: placeBuilding?.passport?.address || placeIncident || undefined,
workType: workType || undefined,
problemDetail: problemDetail || undefined,
isEmergency,
isPaid,
isWarranty,
deadlineAt: deadlineAt ? new Date(deadlineAt).toISOString() : undefined,
executorName: executorName.trim() || undefined,
responsibleName: responsibleName.trim() || undefined,
observersText: observersText.trim() || undefined,
showInApp,
buildingId: buildingId || undefined,
};
await backendApi.createApplication(payload);
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-[100] 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">Новая заявка</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">
Откуда поступила заявка <span className="text-red-500">*</span>
</label>
<select
value={sourceChannel}
onChange={e => setSourceChannel(e.target.value)}
className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{SOURCE_CHANNELS.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">
Адрес (дом) <span className="text-red-500">*</span>
</label>
<select
value={buildingId}
onChange={e => setBuildingId(e.target.value)}
className="w-full px-4 py-3 bg-white 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>
<div className="flex p-1 bg-slate-200/50 rounded-2xl gap-1">
<button
type="button"
onClick={() => setIsFromResident(true)}
className={`flex-1 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all ${isFromResident ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
Заявка от жителя
</button>
<button
type="button"
onClick={() => setIsFromResident(false)}
className={`flex-1 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all ${!isFromResident ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
Заявка не от жителя
</button>
</div>
{isFromResident ? (
<div className="mt-4">
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Заявитель (житель/собственник) <span className="text-red-500">*</span></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 bg-white 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 && (contactPhone || contactName) && (
<p className="text-xs text-slate-500 mt-1">
{contactName}{contactPhone ? ` · ${contactPhone}` : ''}{apartment ? ` · кв. ${apartment}` : ''}
</p>
)}
</div>
) : (
<div className="mt-4 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>
<div className="flex">
<span className="inline-flex items-center px-3 py-2 bg-slate-100 border border-r-0 border-slate-200 rounded-l-xl text-sm text-slate-600">+7</span>
<input
type="tel"
value={contactPhone}
onChange={e => setContactPhone(e.target.value)}
placeholder="900 123-45-67"
className="flex-1 px-4 py-2 border border-slate-200 rounded-r-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">ФИО</label>
<input
type="text"
value={contactName}
onChange={e => setContactName(e.target.value)}
placeholder="Фамилия Имя Отчество"
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</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>
<textarea
value={description}
onChange={e => setDescription(e.target.value.slice(0, MAX_DESCRIPTION_LENGTH))}
placeholder="Опишите подробности"
rows={4}
className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-y"
/>
<p className="text-right text-xs text-slate-500 mt-1">{description.length}/{MAX_DESCRIPTION_LENGTH}</p>
</div>
{/* Добавить файл */}
<div>
<button type="button" className="inline-flex items-center gap-2 px-4 py-2.5 border border-dashed border-slate-300 rounded-xl text-xs font-black text-slate-600 uppercase tracking-wider hover:bg-slate-50 transition-colors">
<Paperclip className="w-4 h-4" /> Добавить файл
</button>
</div>
{/* Классификатор */}
<div className="space-y-4">
<h4 className="text-sm font-black text-slate-800 uppercase tracking-wider">Классификатор</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Место инцидента *</label>
<select
value={placeIncident}
onChange={e => setPlaceIncident(e.target.value)}
className="w-full px-4 py-2 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?.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={workType}
onChange={e => setWorkType(e.target.value)}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{WORK_TYPES.map(opt => (
<option key={opt.value || 'empty'} 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>
<select
value={problemDetail}
onChange={e => setProblemDetail(e.target.value)}
className="w-full px-4 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{PROBLEM_DETAIL_OPTIONS.map(opt => (
<option key={opt.value || 'empty'} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
<div className="flex flex-wrap gap-4">
<label className="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isEmergency} onChange={e => setIsEmergency(e.target.checked)} className="rounded border-slate-300 text-primary-600" />
<span className="text-xs font-bold text-slate-700">Аварийная</span>
</label>
<label className="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isPaid} onChange={e => setIsPaid(e.target.checked)} className="rounded border-slate-300 text-primary-600" />
<span className="text-xs font-bold text-slate-700">Платная</span>
</label>
<label className="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isWarranty} onChange={e => setIsWarranty(e.target.checked)} className="rounded border-slate-300 text-primary-600" />
<span className="text-xs font-bold text-slate-700">Гарантийная</span>
</label>
</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>
<div className="flex items-center gap-2">
<input
type="date"
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" />
</div>
<p className="text-xs text-slate-500 mt-1 flex items-center gap-1">
<HelpCircle className="w-3.5 h-3.5" /> Дата определена автоматически (+8 дн.)
</p>
</div>
{/* Назначение заявки */}
<div className="space-y-4">
<h4 className="text-sm font-black text-slate-800 uppercase tracking-wider">Назначение заявки</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider flex items-center gap-1">
Исполнитель <HelpCircle className="w-3.5 h-3.5 text-slate-400" />
</label>
<select
value={executorName}
onChange={e => setExecutorName(e.target.value)}
className="w-full px-4 py-2 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 flex items-center gap-1">
Ответственный <HelpCircle className="w-3.5 h-3.5 text-slate-400" />
</label>
<select
value={responsibleName}
onChange={e => setResponsibleName(e.target.value)}
className="w-full px-4 py-2 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 flex items-center gap-1">
Наблюдатели <HelpCircle className="w-3.5 h-3.5 text-slate-400" />
</label>
<input
type="text"
value={observersText}
onChange={e => setObserversText(e.target.value)}
placeholder="ФИО через запятую"
className="w-full px-4 py-2 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="inline-flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={showInApp} onChange={e => setShowInApp(e.target.checked)} className="rounded border-slate-300 text-primary-600" />
<span className="text-xs font-bold text-slate-700">Отображать заявку в мобильном приложении жителя</span>
<HelpCircle className="w-3.5 h-3.5 text-slate-400" />
</label>
</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>
);
};