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

366 lines
17 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 } from 'react';
import { X, Calendar, HelpCircle, Sparkles } from 'lucide-react';
import { CreateOutagePayload, OutageWorkType } from '../../types';
import { backendApi } from '../../services/apiClient';
import { Building } from '../../types';
const CATEGORIES = [
{ value: '', label: 'Выбрать' },
{ value: 'water', label: 'Вода' },
{ value: 'electric', label: 'Электрика' },
{ value: 'heating', label: 'Отопление' },
{ value: 'elevator', label: 'Лифт' },
{ value: 'common', label: 'Общедомовое' },
{ value: 'other', label: 'Другое' },
];
const PROBLEMS_BY_CATEGORY: Record<string, { value: string; label: string }[]> = {
water: [
{ value: 'leak', label: 'Течь сантехнических приборов' },
{ value: 'no_water', label: 'Нет воды' },
{ value: 'blockage', label: 'Засор' },
{ value: 'other', label: 'Другое' },
],
electric: [
{ value: 'no_power', label: 'Нет электричества' },
{ value: 'repair', label: 'Ремонт сети' },
{ value: 'other', label: 'Другое' },
],
heating: [
{ value: 'no_heat', label: 'Нет отопления' },
{ value: 'repair', label: 'Ремонт системы' },
{ value: 'other', label: 'Другое' },
],
elevator: [
{ value: 'repair', label: 'Ремонт лифта' },
{ value: 'inspection', label: 'Техобслуживание' },
{ value: 'other', label: 'Другое' },
],
common: [
{ value: 'repair', label: 'Ремонт' },
{ value: 'other', label: 'Другое' },
],
other: [{ value: 'other', label: 'Другое' }],
};
const WORK_TYPES: { value: OutageWorkType; label: string }[] = [
{ value: 'absent', label: 'Отсутствует' },
{ value: 'planned', label: 'Плановый' },
{ value: 'emergency', label: 'Аварийный' },
];
const MAX_WHAT_HAPPENED = 1500;
const MAX_RESIDENT_MESSAGE = 1000;
interface Props {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
/** Если передан — форма «из дома», дом уже выбран; иначе — диспетчерская, выбор адресов */
buildingId?: string;
}
export const CreateOutageCard: React.FC<Props> = ({ isOpen, onClose, onSuccess, buildingId }) => {
const [buildingIds, setBuildingIds] = useState<string[]>([]);
const [selectAll, setSelectAll] = useState(false);
const [startAt, setStartAt] = useState('');
const [endAt, setEndAt] = useState('');
const [category, setCategory] = useState('');
const [problemDetail, setProblemDetail] = useState('');
const [workType, setWorkType] = useState<OutageWorkType>('absent');
const [whatHappened, setWhatHappened] = useState('');
const [residentMessage, setResidentMessage] = useState('');
const [generateNews, setGenerateNews] = useState(false);
const [buildings, setBuildings] = useState<Building[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fromHouse = !!buildingId;
useEffect(() => {
if (isOpen) {
backendApi.getBuildings().then(setBuildings).catch(() => setBuildings([]));
const now = new Date();
setStartAt(now.toISOString().slice(0, 16));
setEndAt('');
if (fromHouse) {
setBuildingIds(buildingId ? [buildingId] : []);
} else {
setBuildingIds([]);
setSelectAll(false);
}
setCategory('');
setProblemDetail('');
setWorkType('absent');
setWhatHappened('');
setResidentMessage('');
setGenerateNews(false);
setError(null);
}
}, [isOpen, buildingId, fromHouse]);
useEffect(() => {
if (selectAll && !fromHouse) {
setBuildingIds(buildings.map(b => b.id));
} else if (!selectAll && !fromHouse) {
setBuildingIds([]);
}
}, [selectAll, fromHouse, buildings]);
if (!isOpen) return null;
const problemOptions = category ? (PROBLEMS_BY_CATEGORY[category] || [{ value: 'other', label: 'Другое' }]) : [];
const selectedBuildingAddresses = fromHouse
? buildings.filter(b => b.id === buildingId).map(b => (b.passport as any)?.address || b.id)
: buildings.filter(b => buildingIds.includes(b.id)).map(b => (b.passport as any)?.address || b.id);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const ids = fromHouse ? (buildingId ? [buildingId] : []) : buildingIds;
if (ids.length === 0) {
setError('Выберите хотя бы один адрес');
return;
}
if (!startAt.trim()) {
setError('Укажите начало работ');
return;
}
if (!category) {
setError('Выберите категорию');
return;
}
if (!whatHappened.trim()) {
setError('Заполните поле «Что случилось»');
return;
}
if (whatHappened.length > MAX_WHAT_HAPPENED) {
setError(`«Что случилось» — не более ${MAX_WHAT_HAPPENED} символов`);
return;
}
setLoading(true);
try {
const categoryLabel = CATEGORIES.find(c => c.value === category)?.label || category;
const payload: CreateOutagePayload = {
buildingId: ids.length === 1 ? ids[0] : undefined,
buildingIds: ids.length > 1 ? ids : undefined,
startAt: new Date(startAt).toISOString(),
endAt: endAt ? new Date(endAt).toISOString() : undefined,
type: categoryLabel,
description: whatHappened.trim(),
authorName: 'Администратор',
category: categoryLabel,
problemDetail: problemOptions.find(p => p.value === problemDetail)?.label || problemDetail || undefined,
workType,
residentMessage: residentMessage.trim() || undefined,
generateNews,
};
await backendApi.createOutage(payload);
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>
{fromHouse ? (
<div className="px-4 py-3 border border-slate-200 rounded-xl text-sm text-slate-800 bg-slate-50">
{selectedBuildingAddresses[0] || buildingId || '—'}
</div>
) : (
<>
<select
multiple
value={buildingIds}
onChange={e => {
const opts = Array.from(e.target.selectedOptions, o => o.value);
setBuildingIds(opts);
}}
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 min-h-[100px]"
>
{buildings.map(b => (
<option key={b.id} value={b.id}>{(b.passport as any)?.address || b.id}</option>
))}
</select>
<label className="inline-flex items-center gap-2 cursor-pointer mt-2">
<input type="checkbox" checked={selectAll} onChange={e => setSelectAll(e.target.checked)} className="rounded border-slate-300 text-primary-600" />
<span className="text-xs font-bold text-slate-700">Выбрать все</span>
</label>
</>
)}
</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="datetime-local"
value={startAt}
onChange={e => setStartAt(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">Завершение работ</label>
<div className="flex items-center gap-2">
<input
type="datetime-local"
value={endAt}
onChange={e => setEndAt(e.target.value)}
min={startAt || undefined}
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={category}
onChange={e => {
setCategory(e.target.value);
setProblemDetail('');
}}
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"
>
{CATEGORIES.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-3 border border-slate-200 rounded-xl text-sm outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">{category ? 'Выбрать' : 'Выберите категорию'}</option>
{problemOptions.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 p-1 bg-slate-200/50 rounded-2xl gap-1">
{WORK_TYPES.map(opt => (
<button
key={opt.value}
type="button"
onClick={() => setWorkType(opt.value)}
className={`flex-1 py-2 rounded-xl text-[10px] font-black uppercase tracking-wider transition-all ${workType === opt.value ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
{opt.label}
</button>
))}
</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={whatHappened}
onChange={e => setWhatHappened(e.target.value.slice(0, MAX_WHAT_HAPPENED))}
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"
/>
<p className="text-right text-[10px] font-bold text-slate-500 mt-1 uppercase">{whatHappened.length}/{MAX_WHAT_HAPPENED}</p>
</div>
{/* Что сказать жителю */}
<div>
<label className="block text-xs font-black text-slate-700 mb-2 uppercase tracking-wider">Что сказать жителю</label>
<textarea
value={residentMessage}
onChange={e => setResidentMessage(e.target.value.slice(0, MAX_RESIDENT_MESSAGE))}
placeholder="Напишите, что скажет жителю сотрудник"
rows={3}
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 className="flex items-center justify-between mt-1">
<button type="button" className="inline-flex items-center gap-1.5 text-xs font-bold text-slate-500 hover:text-primary-600 transition-colors">
<Sparkles className="w-3.5 h-3.5" /> Улучшить текст
</button>
<span className="text-[10px] font-bold text-slate-500 uppercase">{residentMessage.length}/{MAX_RESIDENT_MESSAGE}</span>
</div>
</div>
{/* Сгенерировать новость */}
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-xl border border-slate-100">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-slate-700">Сгенерировать новость для жителей</span>
<HelpCircle className="w-3.5 h-3.5 text-slate-400" />
</div>
<button
type="button"
role="switch"
aria-checked={generateNews}
onClick={() => setGenerateNews(!generateNews)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 ${generateNews ? 'bg-primary-600 border-primary-600' : 'bg-slate-200 border-slate-200'}`}
>
<span className={`absolute top-0.5 left-0.5 inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${generateNews ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</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>
);
};