Files
mkd/components/development/CreateOSSModal.tsx
2026-02-04 00:17:04 +05:00

443 lines
23 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, Plus, Trash2 } from 'lucide-react';
import { DevOSSSession } from '../../types';
import { Building } from '../../types';
import { backendApi } from '../../services/apiClient';
interface Props {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export const CreateOSSModal: React.FC<Props> = ({ isOpen, onClose, onSuccess }) => {
const [formData, setFormData] = useState({
buildingId: '',
address: '',
startDate: '',
endDate: '',
quorumTotal: 0,
type: 'extraordinary' as 'annual' | 'extraordinary',
description: '',
source: 'existing' as 'existing' | 'pipeline',
agendaItems: [] as string[],
});
const [buildings, setBuildings] = useState<Building[]>([]);
const [pipelineAddresses, setPipelineAddresses] = useState<{id: string, address: string}[]>([]);
const [loading, setLoading] = useState(false);
const [loadingBuildings, setLoadingBuildings] = useState(true);
useEffect(() => {
if (isOpen) {
fetchData();
}
}, [isOpen]);
const fetchData = async () => {
try {
setLoadingBuildings(true);
// Загружаем существующие дома
const buildingsData = await backendApi.getBuildings();
setBuildings(buildingsData);
// Загружаем адреса из воронки
try {
const pipelineData = await backendApi.getDevelopmentPipeline();
setPipelineAddresses(pipelineData.map(p => ({ id: p.id, address: p.address })));
} catch (err) {
console.warn('Failed to load pipeline addresses:', err);
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoadingBuildings(false);
}
};
if (!isOpen) return null;
const handleSourceChange = (source: 'existing' | 'pipeline') => {
setFormData({ ...formData, source, buildingId: '', address: '' });
};
const handleBuildingChange = (value: string) => {
if (formData.source === 'existing') {
const building = buildings.find(b => b.id === value);
const totalArea = building?.passport?.general?.totalArea;
setFormData({
...formData,
buildingId: value,
address: building?.passport?.address || '',
quorumTotal: totalArea && totalArea > 0 ? totalArea : 0
});
} else {
const pipelineItem = pipelineAddresses.find(p => p.id === value);
setFormData({
...formData,
buildingId: value,
address: pipelineItem?.address || '',
// Для объектов из воронки площадь не заполняется автоматически
});
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Улучшенная валидация
const address = formData.address?.trim();
const startDate = formData.startDate?.trim();
const endDate = formData.endDate?.trim() || defaultEndDate;
const quorumTotal = formData.quorumTotal;
if (!address || address.length === 0) {
alert('Заполните обязательное поле: адрес');
return;
}
if (!startDate || startDate.length === 0) {
alert('Заполните обязательное поле: дата начала');
return;
}
if (!endDate || endDate.length === 0) {
alert('Заполните обязательное поле: дата окончания');
return;
}
if (!quorumTotal || quorumTotal <= 0) {
alert('Заполните обязательное поле: общая площадь (должна быть больше 0)');
return;
}
if (new Date(endDate) <= new Date(startDate)) {
alert('Дата окончания должна быть позже даты начала');
return;
}
try {
setLoading(true);
const agenda = formData.agendaItems.filter((t) => t.trim()).length > 0 ? formData.agendaItems.filter((t) => t.trim()) : undefined;
const result = await backendApi.createDevelopmentOSS({
address,
buildingId: formData.source === 'existing' ? formData.buildingId : null,
building_id: formData.source === 'existing' ? formData.buildingId : null,
startDate: startDate,
start_date: startDate,
endDate: endDate,
end_date: endDate,
quorumTotal,
quorum_total: quorumTotal,
quorumCurrent: 0,
type: formData.type,
status: 'planned' as const,
description: formData.description?.trim() || null,
agenda_items: agenda,
agendaItems: agenda,
} as any);
console.log('OSS created successfully:', result);
// Закрываем модальное окно и сбрасываем форму перед вызовом onSuccess
// чтобы избежать проблем с обновлением состояния
setFormData({
buildingId: '',
address: '',
startDate: '',
endDate: '',
quorumTotal: 0,
type: 'extraordinary',
description: '',
source: 'existing',
agendaItems: [],
});
onClose();
// Вызываем onSuccess после небольшой задержки, чтобы модальное окно успело закрыться
setTimeout(() => {
window.dispatchEvent(new CustomEvent('mkd-oss-changed'));
window.dispatchEvent(new CustomEvent('mkd-dev-summary-changed'));
onSuccess();
}, 100);
} catch (error: any) {
console.error('Error creating OSS:', error);
const errorMessage = error?.response?.data?.error || error?.message || error?.error || 'Ошибка при создании ОСС';
alert(`Ошибка при создании ОСС: ${errorMessage}`);
} finally {
setLoading(false);
}
};
// Вычисляем дату окончания по умолчанию (через 30 дней)
const defaultEndDate = formData.startDate
? new Date(new Date(formData.startDate).getTime() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
: '';
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md animate-fade-in"
onClick={onClose}
>
<div
className="bg-white rounded-2xl w-full max-w-2xl shadow-2xl animate-slide-up max-h-[90vh] overflow-y-auto"
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">
<h3 className="text-lg font-black text-slate-800">Создать собрание собственников (ОСС)</h3>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-xl transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Выбор источника */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Тип ОСС
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleSourceChange('existing')}
className={`flex-1 p-3 rounded-xl border-2 transition-all ${
formData.source === 'existing'
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-slate-200 bg-white text-slate-600'
}`}
>
<div className="font-bold text-sm mb-1">Существующий дом</div>
<div className="text-[10px] text-slate-500">Для поднятия тарифов</div>
</button>
<button
type="button"
onClick={() => handleSourceChange('pipeline')}
className={`flex-1 p-3 rounded-xl border-2 transition-all ${
formData.source === 'pipeline'
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-slate-200 bg-white text-slate-600'
}`}
>
<div className="font-bold text-sm mb-1">Из воронки</div>
<div className="text-[10px] text-slate-500">Новый объект</div>
</button>
</div>
</div>
{/* Выбор дома/адреса */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
{formData.source === 'existing' ? 'Дом *' : 'Объект из воронки *'}
</label>
{loadingBuildings ? (
<div className="w-full p-2.5 rounded-xl border border-slate-200 text-sm text-slate-400">
Загрузка...
</div>
) : (
<>
{formData.source === 'existing' ? (
<select
required
value={formData.buildingId}
onChange={(e) => handleBuildingChange(e.target.value)}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="">Выберите дом</option>
{buildings.map(building => (
<option key={building.id} value={building.id}>
{building.passport?.address || building.id}
</option>
))}
</select>
) : (
<select
required
value={formData.buildingId}
onChange={(e) => handleBuildingChange(e.target.value)}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="">Выберите объект из воронки</option>
{pipelineAddresses.map(item => (
<option key={item.id} value={item.id}>
{item.address}
</option>
))}
</select>
)}
{formData.source === 'pipeline' && pipelineAddresses.length === 0 && (
<p className="text-xs text-amber-600 mt-2">Нет объектов в воронке. Сначала добавьте объект в воронку.</p>
)}
</>
)}
</div>
{/* Адрес (автозаполняется, но можно редактировать) */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Адрес *
</label>
<input
type="text"
required
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="ул. Примерная, д. 1"
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
{/* Тип ОСС */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Тип собрания
</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'annual' | 'extraordinary' })}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="annual">Ежегодное отчетное собрание</option>
<option value="extraordinary">Внеочередное ОСС</option>
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Дата начала */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Дата начала *
</label>
<input
type="date"
required
value={formData.startDate}
onChange={(e) => setFormData({ ...formData, startDate: e.target.value })}
min={new Date().toISOString().split('T')[0]}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
{/* Дата окончания */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Дата окончания *
</label>
<input
type="date"
required
value={formData.endDate || (formData.startDate ? defaultEndDate : '')}
onChange={(e) => setFormData({ ...formData, endDate: e.target.value })}
min={formData.startDate || new Date().toISOString().split('T')[0]}
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
{formData.startDate && !formData.endDate && (
<p className="text-xs text-slate-400 mt-1">Рекомендуемая дата: {defaultEndDate}</p>
)}
</div>
</div>
{/* Общая площадь для кворума */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Общая площадь дома (м²) *
</label>
<input
type="number"
required
min="1"
step="0.01"
value={formData.quorumTotal || ''}
onChange={(e) => {
const value = e.target.value;
setFormData({ ...formData, quorumTotal: value ? parseFloat(value) : 0 });
}}
placeholder="25000"
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
<p className="text-xs text-slate-400 mt-1">Для расчета кворума (50% + 1 м²). Минимум: 1 м²</p>
{formData.quorumTotal <= 0 && formData.quorumTotal !== 0 && (
<p className="text-xs text-amber-600 mt-1">Площадь должна быть больше 0</p>
)}
</div>
{/* Пункты повестки (голосование по каждому) */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Пункты повестки (голос по каждому)
</label>
<p className="text-xs text-slate-400 mb-2">Добавьте пункты в бюллетене можно будет указать «За» / «Против» / «Воздержался» по каждому.</p>
<div className="space-y-2">
{(formData.agendaItems.length === 0 ? [''] : formData.agendaItems).map((text, idx) => (
<div key={idx} className="flex gap-2 items-center">
<span className="text-[10px] font-bold text-slate-400 w-6">{idx + 1}.</span>
<input
type="text"
value={text}
onChange={(e) => {
const next = [...(formData.agendaItems.length ? formData.agendaItems : [''])];
next[idx] = e.target.value;
setFormData({ ...formData, agendaItems: next });
}}
placeholder="Формулировка пункта"
className="flex-1 p-2 rounded-xl border border-slate-200 text-sm"
/>
<button
type="button"
onClick={() => {
const next = formData.agendaItems.length ? formData.agendaItems.filter((_, i) => i !== idx) : [];
setFormData({ ...formData, agendaItems: next });
}}
className="p-2 text-slate-400 hover:text-red-600"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
<button
type="button"
onClick={() => setFormData({ ...formData, agendaItems: [...(formData.agendaItems.length ? formData.agendaItems : ['']), ''] })}
className="flex items-center gap-2 text-xs font-bold text-primary-600 hover:text-primary-700"
>
<Plus className="w-4 h-4" /> Добавить пункт
</button>
</div>
</div>
{/* Описание */}
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Описание
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
placeholder="Внеочередное ОСС по вопросу смены УК"
className="w-full p-2.5 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none resize-none"
/>
</div>
<div className="flex gap-3 justify-end pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
className="px-6 py-2.5 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold hover:bg-slate-200 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-2.5 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Создание...' : 'Создать ОСС'}
</button>
</div>
</form>
</div>
</div>
);
};