1872 lines
118 KiB
TypeScript
Executable File
1872 lines
118 KiB
TypeScript
Executable File
|
||
import React, { useState, useMemo, useEffect } from 'react';
|
||
import { Building, PassportMeter, PassportServiceContract } from '../../types';
|
||
import {
|
||
FileText, Layers, Zap, Trees, Briefcase, ArrowUpFromLine,
|
||
Gauge, Droplets, Flame, X, BarChart3, History, ChevronRight,
|
||
ShieldCheck, Wifi, Video, Trash2, Plus, Globe, HardHat, Upload, File,
|
||
Calendar, Edit2, Save, Clock, TrendingUp
|
||
} from 'lucide-react';
|
||
import { EditableField } from './EditableField';
|
||
import { AddCustomFieldModal } from './AddCustomFieldModal';
|
||
import { storageService } from '../../services/storageService';
|
||
|
||
// Mock Data Generator for ODPU History
|
||
const generateODPUHistory = (resource: string) => {
|
||
const data = [];
|
||
const today = new Date();
|
||
let currentVal = resource === 'Electricity' ? 450000 : resource === 'Water' ? 12000 : 8500;
|
||
|
||
for (let i = 0; i < 12; i++) {
|
||
const date = new Date(today.getFullYear(), today.getMonth() - i, 1);
|
||
let consumption = 0;
|
||
if (resource === 'Heat') {
|
||
const isWinter = date.getMonth() < 3 || date.getMonth() > 9;
|
||
consumption = isWinter ? 150 + Math.random() * 50 : 0;
|
||
} else if (resource === 'Water') {
|
||
consumption = 800 + Math.random() * 100;
|
||
} else {
|
||
consumption = 12000 + Math.random() * 2000;
|
||
}
|
||
|
||
data.push({
|
||
date: date.toISOString().split('T')[0],
|
||
value: Math.floor(currentVal),
|
||
consumption: Math.floor(consumption)
|
||
});
|
||
currentVal -= consumption;
|
||
}
|
||
return data.reverse();
|
||
};
|
||
|
||
// Модальное окно для создания нового ПУ
|
||
const AddMeterModal: React.FC<{
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onAdd: (meter: PassportMeter) => void;
|
||
}> = ({ isOpen, onClose, onAdd }) => {
|
||
const [resource, setResource] = useState<'Heat' | 'Water' | 'Electricity' | 'Gas' | 'Other'>('Heat');
|
||
const [name, setName] = useState('');
|
||
const [number, setNumber] = useState('');
|
||
const [model, setModel] = useState('');
|
||
const [manufacturer, setManufacturer] = useState('');
|
||
const [unit, setUnit] = useState('');
|
||
const [installDate, setInstallDate] = useState('');
|
||
const [lastVerification, setLastVerification] = useState('');
|
||
const [nextVerification, setNextVerification] = useState('');
|
||
const [currentReading, setCurrentReading] = useState('');
|
||
|
||
const defaultUnits: Record<string, string> = {
|
||
'Heat': 'Гкал',
|
||
'Water': 'м³',
|
||
'Electricity': 'кВт⋅ч',
|
||
'Gas': 'м³',
|
||
'Other': ''
|
||
};
|
||
|
||
const defaultNames: Record<string, string> = {
|
||
'Heat': 'Отопление',
|
||
'Water': 'Водоснабжение',
|
||
'Electricity': 'Электроснабжение',
|
||
'Gas': 'Газоснабжение',
|
||
'Other': ''
|
||
};
|
||
|
||
const handleResourceChange = (newResource: typeof resource) => {
|
||
setResource(newResource);
|
||
if (!name || name === defaultNames[resource]) {
|
||
setName(defaultNames[newResource] || '');
|
||
}
|
||
if (!unit || unit === defaultUnits[resource]) {
|
||
setUnit(defaultUnits[newResource] || '');
|
||
}
|
||
};
|
||
|
||
const handleSubmit = () => {
|
||
if (!name.trim() || !number.trim()) {
|
||
alert('Заполните название и номер прибора');
|
||
return;
|
||
}
|
||
|
||
const newMeter: PassportMeter = {
|
||
id: `meter-${Date.now()}`,
|
||
resource,
|
||
hasMeter: true,
|
||
name: name.trim(),
|
||
number: number.trim(),
|
||
model: model.trim() || undefined,
|
||
manufacturer: manufacturer.trim() || undefined,
|
||
unit: unit.trim() || defaultUnits[resource] || undefined,
|
||
installDate: installDate || undefined,
|
||
lastVerification: lastVerification || undefined,
|
||
nextVerification: nextVerification || undefined,
|
||
currentReading: currentReading ? parseFloat(currentReading) : undefined,
|
||
readings: currentReading ? [{
|
||
date: new Date().toISOString().split('T')[0],
|
||
value: parseFloat(currentReading),
|
||
consumption: 0,
|
||
source: 'manual'
|
||
}] : [],
|
||
documents: []
|
||
};
|
||
|
||
onAdd(newMeter);
|
||
// Сброс формы
|
||
setResource('Heat');
|
||
setName('');
|
||
setNumber('');
|
||
setModel('');
|
||
setManufacturer('');
|
||
setUnit('');
|
||
setInstallDate('');
|
||
setLastVerification('');
|
||
setNextVerification('');
|
||
setCurrentReading('');
|
||
onClose();
|
||
};
|
||
|
||
if (!isOpen) return null;
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={onClose}>
|
||
<div className="bg-white rounded-2xl w-full max-w-lg p-6 shadow-2xl animate-slide-up max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||
<div className="flex justify-between items-center mb-6">
|
||
<h3 className="text-xl font-bold text-slate-800">Добавить прибор учета</h3>
|
||
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-full">
|
||
<X className="w-5 h-5 text-slate-500"/>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="text-xs font-bold text-slate-600 block mb-2">Тип ресурса *</label>
|
||
<select
|
||
value={resource}
|
||
onChange={(e) => handleResourceChange(e.target.value as typeof resource)}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
>
|
||
<option value="Heat">Отопление</option>
|
||
<option value="Water">Водоснабжение</option>
|
||
<option value="Electricity">Электроснабжение</option>
|
||
<option value="Gas">Газоснабжение</option>
|
||
<option value="Other">Другой</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs font-bold text-slate-600 block mb-2">Название прибора *</label>
|
||
<input
|
||
type="text"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder={defaultNames[resource] || 'Введите название'}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs font-bold text-slate-600 block mb-2">Номер прибора *</label>
|
||
<input
|
||
type="text"
|
||
value={number}
|
||
onChange={(e) => setNumber(e.target.value)}
|
||
placeholder="Введите номер"
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-xs font-bold text-slate-600 block mb-2">Модель</label>
|
||
<input
|
||
type="text"
|
||
value={model}
|
||
onChange={(e) => setModel(e.target.value)}
|
||
placeholder="Модель"
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-bold text-slate-600 block mb-2">Производитель</label>
|
||
<input
|
||
type="text"
|
||
value={manufacturer}
|
||
onChange={(e) => setManufacturer(e.target.value)}
|
||
placeholder="Производитель"
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs font-bold text-slate-600 block mb-2">Единица измерения</label>
|
||
<input
|
||
type="text"
|
||
value={unit}
|
||
onChange={(e) => setUnit(e.target.value)}
|
||
placeholder={defaultUnits[resource] || 'Единица измерения'}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-xs font-bold text-slate-600 block mb-2">Дата установки</label>
|
||
<input
|
||
type="date"
|
||
value={installDate}
|
||
onChange={(e) => setInstallDate(e.target.value)}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-bold text-slate-600 block mb-2">Текущее показание</label>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={currentReading}
|
||
onChange={(e) => setCurrentReading(e.target.value)}
|
||
placeholder="0"
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-xs font-bold text-slate-600 block mb-2">Последняя поверка</label>
|
||
<input
|
||
type="date"
|
||
value={lastVerification}
|
||
onChange={(e) => setLastVerification(e.target.value)}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs font-bold text-slate-600 block mb-2">Следующая поверка</label>
|
||
<input
|
||
type="date"
|
||
value={nextVerification}
|
||
onChange={(e) => setNextVerification(e.target.value)}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4 border-t border-slate-200">
|
||
<button
|
||
onClick={handleSubmit}
|
||
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors"
|
||
>
|
||
Добавить
|
||
</button>
|
||
<button
|
||
onClick={onClose}
|
||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg font-bold text-sm hover:bg-slate-50 transition-colors"
|
||
>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Модальное окно карточки ПУ
|
||
const MeterCardModal: React.FC<{
|
||
meter: PassportMeter;
|
||
meterIndex: number;
|
||
isEditing: boolean;
|
||
onClose: () => void;
|
||
onUpdate: (index: number, field: string, value: any) => void;
|
||
onAddReading: (index: number, reading: { date: string; value: number; consumption: number }) => void;
|
||
}> = ({ meter, meterIndex, isEditing, onClose, onUpdate, onAddReading }) => {
|
||
const [isEditingCard, setIsEditingCard] = useState(false);
|
||
const [newReadingDate, setNewReadingDate] = useState('');
|
||
const [newReadingValue, setNewReadingValue] = useState('');
|
||
|
||
const history = useMemo(() => {
|
||
if (meter.readings && meter.readings.length > 0) {
|
||
// Сортируем по дате (от новых к старым) и рассчитываем потребление, если его нет
|
||
const sorted = [...meter.readings].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||
|
||
// Если потребление не указано, рассчитываем его из разницы показаний
|
||
return sorted.map((reading, index) => {
|
||
if (reading.consumption !== undefined && reading.consumption > 0) {
|
||
return reading;
|
||
}
|
||
// Ищем предыдущее показание для расчета потребления
|
||
const previousReading = index < sorted.length - 1 ? sorted[index + 1] : null;
|
||
if (previousReading && previousReading.value !== undefined) {
|
||
const consumption = Math.max(0, reading.value - previousReading.value);
|
||
return { ...reading, consumption };
|
||
}
|
||
return { ...reading, consumption: 0 };
|
||
});
|
||
}
|
||
return [];
|
||
}, [meter.readings]);
|
||
|
||
// Статистика по показаниям (только из реальных данных)
|
||
const stats = useMemo(() => {
|
||
if (!history || history.length === 0) {
|
||
return {
|
||
total: 0,
|
||
average: 0,
|
||
max: 0,
|
||
min: 0,
|
||
count: 0,
|
||
period: { start: null, end: null }
|
||
};
|
||
}
|
||
|
||
// Берем только записи с потреблением > 0
|
||
const readingsWithConsumption = history.filter(r => r.consumption !== undefined && r.consumption > 0);
|
||
|
||
if (readingsWithConsumption.length === 0) {
|
||
return {
|
||
total: 0,
|
||
average: 0,
|
||
max: 0,
|
||
min: 0,
|
||
count: history.length,
|
||
period: history.length > 0 ? {
|
||
start: new Date(Math.min(...history.map(r => new Date(r.date).getTime()))),
|
||
end: new Date(Math.max(...history.map(r => new Date(r.date).getTime())))
|
||
} : { start: null, end: null }
|
||
};
|
||
}
|
||
|
||
const consumptions = readingsWithConsumption.map(r => r.consumption || 0);
|
||
const dates = readingsWithConsumption.map(r => new Date(r.date));
|
||
|
||
return {
|
||
total: consumptions.reduce((sum, val) => sum + val, 0),
|
||
average: consumptions.reduce((sum, val) => sum + val, 0) / consumptions.length,
|
||
max: Math.max(...consumptions),
|
||
min: Math.min(...consumptions),
|
||
count: history.length, // Общее количество записей
|
||
period: {
|
||
start: new Date(Math.min(...dates.map(d => d.getTime()))),
|
||
end: new Date(Math.max(...dates.map(d => d.getTime())))
|
||
}
|
||
};
|
||
}, [history]);
|
||
|
||
const maxVal = Math.max(...history.map(r => r.consumption || 0), 1);
|
||
|
||
let colorClass = 'bg-slate-500';
|
||
let textClass = 'text-slate-600';
|
||
let label: string = meter.name || meter.resource;
|
||
let unit = meter.unit || 'ед.';
|
||
let Icon = Gauge;
|
||
|
||
switch(meter.resource) {
|
||
case 'Heat': Icon = Flame; colorClass = 'bg-red-400'; textClass = 'text-red-600'; label = meter.name || 'Отопление'; unit = meter.unit || 'Гкал'; break;
|
||
case 'Water': Icon = Droplets; colorClass = 'bg-blue-400'; textClass = 'text-blue-600'; label = meter.name || 'Водоснабжение'; unit = meter.unit || 'м³'; break;
|
||
case 'Electricity': Icon = Zap; colorClass = 'bg-amber-400'; textClass = 'text-amber-600'; label = meter.name || 'Электроснабжение'; unit = meter.unit || 'кВт⋅ч'; break;
|
||
case 'Gas': Icon = Gauge; colorClass = 'bg-slate-500'; textClass = 'text-slate-600'; label = meter.name || 'Газоснабжение'; unit = meter.unit || 'м³'; break;
|
||
case 'Other': Icon = Gauge; colorClass = 'bg-slate-500'; textClass = 'text-slate-600'; label = meter.name || 'Другой'; break;
|
||
}
|
||
|
||
const handleAddReading = () => {
|
||
if (!newReadingDate || !newReadingValue) return;
|
||
const value = parseFloat(newReadingValue);
|
||
if (isNaN(value)) return;
|
||
|
||
const previousReading = history.length > 0 ? history[0].value : 0;
|
||
const consumption = Math.max(0, value - previousReading);
|
||
|
||
onAddReading(meterIndex, {
|
||
date: newReadingDate,
|
||
value: value,
|
||
consumption: consumption,
|
||
source: 'manual'
|
||
});
|
||
|
||
setNewReadingDate('');
|
||
setNewReadingValue('');
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={onClose}>
|
||
<div className="bg-white rounded-2xl w-full max-w-2xl p-6 shadow-2xl animate-slide-up max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||
<div className="flex justify-between items-start mb-6">
|
||
<div className="flex items-center gap-3">
|
||
<div className={`p-3 rounded-xl ${colorClass.replace('bg-', 'bg-').replace('text-', 'text-')}`}>
|
||
<Icon className="w-6 h-6 text-white" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-xl font-bold text-slate-800">{label}</h3>
|
||
<p className="text-sm text-slate-500">№ {meter.number || 'Не указан'} {meter.model && `• ${meter.model}`}</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => setIsEditingCard(!isEditingCard)}
|
||
className="p-2 hover:bg-slate-100 rounded-full"
|
||
title={isEditingCard ? "Сохранить" : "Редактировать"}
|
||
>
|
||
{isEditingCard ? <Save className="w-5 h-5 text-primary-600"/> : <Edit2 className="w-5 h-5 text-slate-500"/>}
|
||
</button>
|
||
)}
|
||
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-full">
|
||
<X className="w-5 h-5 text-slate-500"/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-6">
|
||
{/* Основная информация */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-1">Тип ресурса</label>
|
||
{isEditingCard ? (
|
||
<select
|
||
value={meter.resource}
|
||
onChange={(e) => onUpdate(meterIndex, 'resource', e.target.value)}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
>
|
||
<option value="Heat">Отопление</option>
|
||
<option value="Water">Водоснабжение</option>
|
||
<option value="Electricity">Электроснабжение</option>
|
||
<option value="Gas">Газоснабжение</option>
|
||
<option value="Other">Другой</option>
|
||
</select>
|
||
) : (
|
||
<p className="font-bold text-slate-800">{label}</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-1">Название</label>
|
||
{isEditingCard ? (
|
||
<input
|
||
type="text"
|
||
value={meter.name || ''}
|
||
onChange={(e) => onUpdate(meterIndex, 'name', e.target.value)}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
) : (
|
||
<p className="font-bold text-slate-800">{meter.name || label}</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-1">Номер прибора</label>
|
||
{isEditingCard ? (
|
||
<input
|
||
type="text"
|
||
value={meter.number || ''}
|
||
onChange={(e) => onUpdate(meterIndex, 'number', e.target.value)}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
) : (
|
||
<p className="font-bold text-slate-800">{meter.number || 'Не указан'}</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-1">Модель</label>
|
||
{isEditingCard ? (
|
||
<input
|
||
type="text"
|
||
value={meter.model || ''}
|
||
onChange={(e) => onUpdate(meterIndex, 'model', e.target.value)}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
) : (
|
||
<p className="font-bold text-slate-800">{meter.model || 'Не указана'}</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-1">Производитель</label>
|
||
{isEditingCard ? (
|
||
<input
|
||
type="text"
|
||
value={meter.manufacturer || ''}
|
||
onChange={(e) => onUpdate(meterIndex, 'manufacturer', e.target.value)}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
) : (
|
||
<p className="font-bold text-slate-800">{meter.manufacturer || 'Не указан'}</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-1">Дата установки</label>
|
||
{isEditingCard ? (
|
||
<input
|
||
type="date"
|
||
value={meter.installDate || ''}
|
||
onChange={(e) => onUpdate(meterIndex, 'installDate', e.target.value)}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
) : (
|
||
<p className="font-bold text-slate-800">{meter.installDate || 'Не указана'}</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-1">Последняя поверка</label>
|
||
{isEditingCard ? (
|
||
<input
|
||
type="date"
|
||
value={meter.lastVerification || ''}
|
||
onChange={(e) => onUpdate(meterIndex, 'lastVerification', e.target.value)}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
) : (
|
||
<p className="font-bold text-slate-800">{meter.lastVerification || 'Не указана'}</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-1">Следующая поверка</label>
|
||
{isEditingCard ? (
|
||
<input
|
||
type="date"
|
||
value={meter.nextVerification || ''}
|
||
onChange={(e) => onUpdate(meterIndex, 'nextVerification', e.target.value)}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
) : (
|
||
<p className={`font-bold ${meter.nextVerification && new Date(meter.nextVerification) < new Date() ? 'text-red-600' : 'text-slate-800'}`}>
|
||
{meter.nextVerification || 'Не указана'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-1">Единица измерения</label>
|
||
{isEditingCard ? (
|
||
<input
|
||
type="text"
|
||
value={meter.unit || ''}
|
||
onChange={(e) => onUpdate(meterIndex, 'unit', e.target.value)}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
) : (
|
||
<p className="font-bold text-slate-800">{unit}</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500 block mb-1">Текущее показание ({unit})</label>
|
||
{isEditingCard ? (
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={meter.currentReading || ''}
|
||
onChange={(e) => onUpdate(meterIndex, 'currentReading', parseFloat(e.target.value) || 0)}
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
) : (
|
||
<p className="font-bold text-slate-800 text-lg">{meter.currentReading?.toLocaleString('ru-RU') || 'Не указано'}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Статистика */}
|
||
{stats.count > 0 && (
|
||
<div className="border-t border-slate-200 pt-4">
|
||
<h4 className="font-bold text-sm text-slate-800 flex items-center gap-2 mb-4">
|
||
<BarChart3 className="w-4 h-4" /> Статистика показаний
|
||
</h4>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="bg-slate-50 p-3 rounded-lg">
|
||
<p className="text-xs text-slate-500 mb-1">Общее потребление</p>
|
||
<p className="font-bold text-lg text-slate-800">{stats.total.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} {unit}</p>
|
||
</div>
|
||
<div className="bg-slate-50 p-3 rounded-lg">
|
||
<p className="text-xs text-slate-500 mb-1">Среднее потребление</p>
|
||
<p className="font-bold text-lg text-slate-800">{stats.average.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} {unit}</p>
|
||
</div>
|
||
<div className="bg-slate-50 p-3 rounded-lg">
|
||
<p className="text-xs text-slate-500 mb-1">Максимальное</p>
|
||
<p className="font-bold text-lg text-slate-800">{stats.max.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} {unit}</p>
|
||
</div>
|
||
<div className="bg-slate-50 p-3 rounded-lg">
|
||
<p className="text-xs text-slate-500 mb-1">Минимальное</p>
|
||
<p className="font-bold text-lg text-slate-800">{stats.min.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} {unit}</p>
|
||
</div>
|
||
<div className="bg-slate-50 p-3 rounded-lg col-span-2">
|
||
<p className="text-xs text-slate-500 mb-1">Период</p>
|
||
<p className="font-bold text-sm text-slate-800">
|
||
{stats.period.start && stats.period.end
|
||
? `${stats.period.start.toLocaleDateString('ru-RU')} - ${stats.period.end.toLocaleDateString('ru-RU')}`
|
||
: 'Не указан'}
|
||
</p>
|
||
<p className="text-xs text-slate-500 mt-1">Всего записей: {stats.count}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* История показаний */}
|
||
<div className="border-t border-slate-200 pt-4">
|
||
<div className="flex justify-between items-center mb-4">
|
||
<h4 className="font-bold text-sm text-slate-800 flex items-center gap-2">
|
||
<History className="w-4 h-4" /> История показаний
|
||
</h4>
|
||
{isEditingCard && (
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="date"
|
||
value={newReadingDate}
|
||
onChange={(e) => setNewReadingDate(e.target.value)}
|
||
className="px-2 py-1 border border-slate-300 rounded text-xs"
|
||
placeholder="Дата"
|
||
/>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={newReadingValue}
|
||
onChange={(e) => setNewReadingValue(e.target.value)}
|
||
className="px-2 py-1 border border-slate-300 rounded text-xs w-24"
|
||
placeholder="Показание"
|
||
/>
|
||
<button
|
||
onClick={handleAddReading}
|
||
className="px-3 py-1 bg-primary-600 text-white rounded text-xs hover:bg-primary-700"
|
||
>
|
||
Добавить
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{history.length > 0 ? (
|
||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||
{history.map((reading, idx) => (
|
||
<div key={idx} className="flex justify-between items-center p-2 bg-slate-50 rounded text-xs">
|
||
<div className="flex items-center gap-2">
|
||
<Calendar className="w-3 h-3 text-slate-400" />
|
||
<span className="text-slate-600">{new Date(reading.date).toLocaleDateString('ru-RU')}</span>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<span className="font-bold text-slate-800">{reading.value?.toLocaleString('ru-RU', { maximumFractionDigits: 2 }) || 0} {unit}</span>
|
||
{reading.consumption !== undefined && reading.consumption > 0 && (
|
||
<span className="text-slate-500 flex items-center gap-1">
|
||
<TrendingUp className="w-3 h-3" />
|
||
{reading.consumption.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} {unit}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-xs text-slate-400 text-center py-4">Нет данных о показаниях. Добавьте первое показание.</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* График потребления (только реальные данные) */}
|
||
{history.length > 0 && (() => {
|
||
const readingsWithConsumption = history.filter(r => r.consumption !== undefined && r.consumption > 0);
|
||
const maxConsumption = Math.max(...readingsWithConsumption.map(r => r.consumption || 0), 1);
|
||
|
||
return readingsWithConsumption.length > 0 ? (
|
||
<div className="border-t border-slate-200 pt-4">
|
||
<h4 className="font-bold text-xs text-slate-500 uppercase mb-4 flex items-center gap-1">
|
||
<BarChart3 className="w-4 h-4"/> Потребление за период ({unit})
|
||
</h4>
|
||
<div className="h-32 flex items-end justify-between gap-1 pb-1 bg-slate-50 p-2 rounded">
|
||
{readingsWithConsumption.slice(0, 12).reverse().map((r, i) => (
|
||
<div key={i} className="flex flex-col items-center flex-1 group relative h-full justify-end">
|
||
<div
|
||
className={`w-full max-w-[20px] rounded-t min-h-[4px] transition-all hover:opacity-80 ${colorClass}`}
|
||
style={{ height: `${((r.consumption || 0) / maxConsumption) * 100}%` }}
|
||
title={`${new Date(r.date).toLocaleDateString('ru-RU')}: ${(r.consumption || 0).toLocaleString('ru-RU')} ${unit}`}
|
||
></div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null;
|
||
})()}
|
||
|
||
{/* Документы */}
|
||
{isEditingCard && (
|
||
<div className="border-t border-slate-200 pt-4">
|
||
<label className="text-xs text-slate-500 block mb-2">Документы</label>
|
||
<input
|
||
type="file"
|
||
multiple
|
||
onChange={(e) => {
|
||
if (!e.target.files) return;
|
||
const fileList = e.target.files;
|
||
const newFiles = Array.from(fileList).map((file: File) => URL.createObjectURL(file as Blob));
|
||
const updatedDocuments = [...(meter.documents || []), ...newFiles];
|
||
onUpdate(meterIndex, 'documents', updatedDocuments);
|
||
}}
|
||
className="text-xs"
|
||
/>
|
||
{meter.documents && meter.documents.length > 0 && (
|
||
<div className="mt-2 space-y-1">
|
||
{meter.documents.map((doc, docIdx) => (
|
||
<div key={docIdx} className="flex items-center gap-2 text-xs text-slate-600 bg-slate-50 p-1.5 rounded">
|
||
<File className="w-3 h-3" />
|
||
<span className="flex-1 truncate">Документ {docIdx + 1}</span>
|
||
<button
|
||
onClick={() => {
|
||
const updatedDocuments = meter.documents!.filter((_, i) => i !== docIdx);
|
||
onUpdate(meterIndex, 'documents', updatedDocuments);
|
||
}}
|
||
className="text-red-500 hover:text-red-700"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Вынесенный компонент поля паспорта — стабильная идентичность предотвращает потерю фокуса при вводе
|
||
const PassportField: React.FC<{
|
||
label: string;
|
||
value: any;
|
||
section: keyof Building['passport'];
|
||
fieldKey: string;
|
||
type?: string;
|
||
files?: string[];
|
||
onFilesChange?: (files: string[]) => void;
|
||
isCustomField?: boolean;
|
||
customFieldKey?: string;
|
||
isEditing: boolean;
|
||
onValueChange: (section: keyof Building['passport'], fieldKey: string, value: any, opts?: { isCustomField: boolean; customFieldKey?: string }) => void;
|
||
}> = ({ label, value, section, fieldKey, type = 'text', files, onFilesChange, isCustomField = false, customFieldKey, isEditing, onValueChange }) => {
|
||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (!e.target.files || !onFilesChange) return;
|
||
const fileList = e.target.files;
|
||
const newFiles = Array.from(fileList).map((file: File) => {
|
||
return URL.createObjectURL(file as Blob);
|
||
});
|
||
onFilesChange([...(files || []), ...newFiles]);
|
||
};
|
||
|
||
const handleValueChange = (v: string) => {
|
||
setTimeout(() => {
|
||
try {
|
||
let processedValue: any = v;
|
||
if (type === 'number') {
|
||
if (v === '' || v === null || v === undefined) {
|
||
processedValue = null;
|
||
} else {
|
||
const numValue = Number(v);
|
||
processedValue = isNaN(numValue) ? null : numValue;
|
||
}
|
||
} else if (type === 'checkbox') {
|
||
processedValue = v === 'true';
|
||
}
|
||
onValueChange(section, fieldKey, processedValue, { isCustomField, customFieldKey });
|
||
} catch (error) {
|
||
console.error('Error in handleValueChange:', error, { section, fieldKey, value: v, type });
|
||
}
|
||
}, 0);
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<label className="block text-xs font-bold text-slate-500 mb-1">{label}</label>
|
||
<EditableField
|
||
value={value || ''}
|
||
onChange={handleValueChange}
|
||
isEditing={isEditing}
|
||
type={type as any}
|
||
className="text-sm text-slate-800 font-medium"
|
||
/>
|
||
{isEditing && onFilesChange && (
|
||
<div className="mt-2">
|
||
<label className="flex items-center gap-2 text-xs text-primary-600 cursor-pointer hover:text-primary-700">
|
||
<Upload className="w-3 h-3" />
|
||
<span className="font-bold">Загрузить файл</span>
|
||
<input
|
||
type="file"
|
||
multiple
|
||
onChange={handleFileUpload}
|
||
className="hidden"
|
||
/>
|
||
</label>
|
||
{files && files.length > 0 && (
|
||
<div className="mt-2 space-y-1">
|
||
{files.map((file, idx) => (
|
||
<div key={idx} className="flex items-center gap-2 text-xs text-slate-600 bg-slate-50 p-1.5 rounded">
|
||
<File className="w-3 h-3" />
|
||
<span className="flex-1 truncate">{file}</span>
|
||
<button
|
||
onClick={() => onFilesChange(files.filter((_, i) => i !== idx))}
|
||
className="text-red-500 hover:text-red-700"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{!isEditing && files && files.length > 0 && (
|
||
<div className="mt-2 space-y-1">
|
||
{files.map((file, idx) => (
|
||
<a key={idx} href={file} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs text-primary-600 hover:text-primary-700">
|
||
<File className="w-3 h-3" />
|
||
<span className="truncate">Файл {idx + 1}</span>
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export const PassportView: React.FC<{
|
||
building: Building;
|
||
isEditing: boolean;
|
||
updatePassport: (section: keyof Building['passport'], field: string, value: any) => void;
|
||
updatePassportArray: (section: 'meters' | 'lifts', index: number, field: string, value: any) => void;
|
||
}> = ({ building, isEditing, updatePassport, updatePassportArray }) => {
|
||
const { general, construction, engineering, odpu = { customFields: {} }, land, management = { contractDate: '', contractNumber: '', tariffMaintenance: null, reserveFund: null, serviceContracts: [], customFields: {} }, meters, lifts } = building.passport;
|
||
const [selectedMeter, setSelectedMeter] = useState<{ meter: PassportMeter; index: number } | null>(null);
|
||
const [showAddFieldModal, setShowAddFieldModal] = useState(false);
|
||
const [showAddMeterModal, setShowAddMeterModal] = useState(false);
|
||
const [currentSection, setCurrentSection] = useState<'general' | 'construction' | 'engineering' | 'odpu' | 'land' | 'management' | null>(null);
|
||
|
||
const serviceContracts = management.serviceContracts || [];
|
||
|
||
// Вычисляем данные из лицевых счетов
|
||
const accountsStats = useMemo(() => {
|
||
const accounts = building.accounts || [];
|
||
const residential = accounts.filter(a => a.type === 'apartment').length;
|
||
const nonResidential = accounts.filter(a => a.type === 'storage' || a.type === 'parking').length;
|
||
const offices = accounts.filter(a => a.type === 'office').length;
|
||
const apartments = accounts.filter(a => a.type === 'apartment').length;
|
||
const nonLivingCommonArea = accounts
|
||
.filter(a => a.type === 'storage' || a.type === 'parking')
|
||
.reduce((sum, a) => sum + (a.areaNonLiving || 0), 0);
|
||
|
||
return {
|
||
residentialAccountsCount: residential,
|
||
nonResidentialAccountsCount: nonResidential,
|
||
officesAccountsCount: offices,
|
||
apartmentsCount: apartments,
|
||
nonLivingCommonArea
|
||
};
|
||
}, [building.accounts]);
|
||
|
||
const handleAddContract = () => {
|
||
const newContract: PassportServiceContract = {
|
||
id: `sc-${Date.now()}`,
|
||
serviceType: 'Новая услуга',
|
||
providerName: 'Наименование организации',
|
||
contractNumber: '№ договора',
|
||
contractDate: new Date().toISOString().split('T')[0]
|
||
};
|
||
updatePassport('management', 'serviceContracts', [...serviceContracts, newContract]);
|
||
};
|
||
|
||
const handleUpdateContract = (id: string, field: keyof PassportServiceContract, value: any) => {
|
||
const updated = serviceContracts.map(c => c.id === id ? { ...c, [field]: value } : c);
|
||
updatePassport('management', 'serviceContracts', updated);
|
||
};
|
||
|
||
const handleRemoveContract = (id: string) => {
|
||
updatePassport('management', 'serviceContracts', serviceContracts.filter(c => c.id !== id));
|
||
};
|
||
|
||
const Section: React.FC<{ title: string; children: React.ReactNode; icon?: React.ElementType; noGrid?: boolean }> = ({ title, children, icon: Icon, noGrid }) => (
|
||
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm mb-4 relative">
|
||
<h3 className="font-bold text-slate-800 text-sm mb-3 border-b border-slate-100 pb-2 flex items-center justify-between relative">
|
||
<span className="flex items-center gap-2">
|
||
{Icon && <Icon className="w-4 h-4 text-primary-500"/>}
|
||
{title}
|
||
</span>
|
||
{isEditing && (
|
||
<div className="absolute top-0 right-0">
|
||
<Edit2 className="w-4 h-4 text-primary-600" title="Режим редактирования" />
|
||
</div>
|
||
)}
|
||
</h3>
|
||
<div className={noGrid ? "" : "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"}>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const getServiceIcon = (type: string) => {
|
||
const lower = type.toLowerCase();
|
||
if (lower.includes('вод')) return Droplets;
|
||
if (lower.includes('газ')) return Flame;
|
||
if (lower.includes('свет') || lower.includes('энерго')) return Zap;
|
||
if (lower.includes('интернет') || lower.includes('связь')) return Wifi;
|
||
if (lower.includes('видео') || lower.includes('наблюд')) return Video;
|
||
if (lower.includes('клин') || lower.includes('уборк')) return HardHat;
|
||
if (lower.includes('лифт')) return ArrowUpFromLine;
|
||
return FileText;
|
||
};
|
||
|
||
const handleFieldValueChange = (section: keyof Building['passport'], fieldKey: string, value: any, opts?: { isCustomField: boolean; customFieldKey?: string }) => {
|
||
if (opts?.isCustomField && opts?.customFieldKey && (section === 'general' || section === 'construction' || section === 'engineering' || section === 'odpu' || section === 'land' || section === 'management')) {
|
||
updateCustomField(section, opts.customFieldKey, value);
|
||
} else {
|
||
updatePassport(section, fieldKey, value);
|
||
}
|
||
};
|
||
|
||
// Функция для работы с кастомными полями
|
||
const updateCustomField = (section: 'general' | 'construction' | 'engineering' | 'odpu' | 'land' | 'management', fieldKey: string, value: any, files?: string[]) => {
|
||
// Проверяем, является ли это поле глобальным
|
||
const isGlobal = globalFields[section] && globalFields[section][fieldKey];
|
||
|
||
if (isGlobal) {
|
||
// Обновляем глобальное поле (это обновит все дома)
|
||
(storageService.updateGlobalPassportField as any)(section, fieldKey, value, files);
|
||
// Обновляем текущий дом
|
||
const sectionData = building.passport[section] as any;
|
||
const customFields = { ...(sectionData.customFields || {}) };
|
||
customFields[fieldKey] = {
|
||
value,
|
||
type: globalFields[section][fieldKey].type,
|
||
files: files !== undefined ? files : (customFields[fieldKey]?.files || [])
|
||
};
|
||
updatePassport(section, 'customFields', customFields);
|
||
} else {
|
||
// Обновляем только для текущего дома
|
||
const sectionData = building.passport[section] as any;
|
||
const customFields = { ...(sectionData.customFields || {}) };
|
||
customFields[fieldKey] = {
|
||
value,
|
||
type: typeof value === 'number' ? 'number' : typeof value === 'boolean' ? 'checkbox' : 'text',
|
||
files: files !== undefined ? files : (customFields[fieldKey]?.files || [])
|
||
};
|
||
updatePassport(section, 'customFields', customFields);
|
||
}
|
||
};
|
||
|
||
const addCustomField = (section: 'general' | 'construction' | 'engineering' | 'odpu' | 'land' | 'management') => {
|
||
setCurrentSection(section);
|
||
setShowAddFieldModal(true);
|
||
};
|
||
|
||
const handleConfirmAddField = (fieldName: string, fieldType: 'text' | 'number' | 'checkbox', forAllBuildings: boolean) => {
|
||
if (!currentSection) return;
|
||
|
||
if (forAllBuildings) {
|
||
// Сохраняем как глобальное поле для всех домов
|
||
storageService.saveGlobalPassportField(currentSection, fieldName, fieldType);
|
||
// Обновляем текущий дом, чтобы поле сразу отобразилось
|
||
const globalFields = storageService.getGlobalPassportFields();
|
||
if (globalFields[currentSection] && globalFields[currentSection][fieldName]) {
|
||
const sectionData = building.passport[currentSection] as any;
|
||
const customFields = { ...(sectionData.customFields || {}) };
|
||
customFields[fieldName] = globalFields[currentSection][fieldName];
|
||
updatePassport(currentSection, 'customFields', customFields);
|
||
}
|
||
} else {
|
||
// Создаем поле только для текущего дома
|
||
const sectionData = building.passport[currentSection] as any;
|
||
const customFields = { ...(sectionData.customFields || {}) };
|
||
const defaultValue = fieldType === 'number' ? 0 : fieldType === 'checkbox' ? false : '';
|
||
customFields[fieldName] = {
|
||
value: defaultValue,
|
||
type: fieldType,
|
||
files: []
|
||
};
|
||
updatePassport(currentSection, 'customFields', customFields);
|
||
}
|
||
};
|
||
|
||
// Загружаем глобальные поля при монтировании компонента
|
||
const globalFields = useMemo(() => {
|
||
return storageService.getGlobalPassportFields();
|
||
}, []);
|
||
|
||
// Применяем глобальные поля к текущему дому при загрузке
|
||
useEffect(() => {
|
||
if (!isEditing) return; // Применяем только в режиме редактирования
|
||
|
||
(['general', 'construction', 'engineering', 'odpu', 'land', 'management'] as const).forEach(section => {
|
||
if (globalFields[section]) {
|
||
const sectionData = building.passport[section] as any;
|
||
const customFields = { ...(sectionData.customFields || {}) };
|
||
|
||
// Добавляем глобальные поля, если их еще нет
|
||
Object.keys(globalFields[section]).forEach(fieldName => {
|
||
if (!customFields[fieldName]) {
|
||
customFields[fieldName] = { ...globalFields[section][fieldName] };
|
||
const updatedSection = { ...sectionData, customFields };
|
||
updatePassport(section, 'customFields', customFields);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}, [isEditing, globalFields]);
|
||
|
||
return (
|
||
<div className="animate-fade-in pb-10">
|
||
{showAddFieldModal && (
|
||
<AddCustomFieldModal
|
||
isOpen={showAddFieldModal}
|
||
onClose={() => {
|
||
setShowAddFieldModal(false);
|
||
setCurrentSection(null);
|
||
}}
|
||
onConfirm={handleConfirmAddField}
|
||
section={currentSection || undefined}
|
||
/>
|
||
)}
|
||
|
||
<Section title="1. Общие сведения" icon={FileText}>
|
||
<PassportField label="Адрес (ФИАС)" value={general.address} section="general" fieldKey="address" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Код ФИАС" value={general.fiasCode} section="general" fieldKey="fiasCode" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Год постройки" value={general.constructionYear} section="general" fieldKey="constructionYear" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Год ввода" value={general.commissionYear} section="general" fieldKey="commissionYear" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Последний кап.ремонт" value={general.lastRepairYear || ''} section="general" fieldKey="lastRepairYear" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Серия проекта" value={general.seriesType} section="general" fieldKey="seriesType" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Этажность" value={general.floors} section="general" fieldKey="floors" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Подземных этажей" value={general.undergroundFloors} section="general" fieldKey="undergroundFloors" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Кадастровый № (Дом)" value={general.cadastralNumberBuild} section="general" fieldKey="cadastralNumberBuild" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Кадастровый № (Земля)" value={general.cadastralNumberLand} section="general" fieldKey="cadastralNumberLand" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Общая площадь (м²)" value={general.totalArea} section="general" fieldKey="totalArea" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Жилая площадь (м²)" value={general.livingArea} section="general" fieldKey="livingArea" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Нежилая площадь (м²)" value={general.nonLivingArea} section="general" fieldKey="nonLivingArea" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="МОП (м²)" value={general.commonArea} section="general" fieldKey="commonArea" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
|
||
{/* Новые поля */}
|
||
<PassportField label="Кол-во подъездов" value={general.entrancesCount || ''} section="general" fieldKey="entrancesCount" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Разноэтажность" value={general.hasDifferentFloors || false} section="general" fieldKey="hasDifferentFloors" type="checkbox" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Кол-во офисов" value={general.officesCount || ''} section="general" fieldKey="officesCount" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
|
||
{/* Вычисляемые поля из лицевых счетов */}
|
||
<div className="bg-slate-50 p-3 rounded-lg border border-slate-200">
|
||
<label className="block text-xs font-bold text-slate-500 mb-2">Данные из лицевых счетов:</label>
|
||
<div className="space-y-1 text-xs">
|
||
<div className="flex justify-between"><span className="text-slate-600">Жилых лицевых счетов:</span><span className="font-bold text-slate-800">{accountsStats.residentialAccountsCount}</span></div>
|
||
<div className="flex justify-between"><span className="text-slate-600">Нежилых лицевых счетов:</span><span className="font-bold text-slate-800">{accountsStats.nonResidentialAccountsCount}</span></div>
|
||
<div className="flex justify-between"><span className="text-slate-600">Офисов (из счетов):</span><span className="font-bold text-slate-800">{accountsStats.officesAccountsCount}</span></div>
|
||
<div className="flex justify-between"><span className="text-slate-600">Квартир (из счетов):</span><span className="font-bold text-slate-800">{accountsStats.apartmentsCount}</span></div>
|
||
<div className="flex justify-between"><span className="text-slate-600">Площадь нежилых в ОДИ (м²):</span><span className="font-bold text-slate-800">{accountsStats.nonLivingCommonArea.toFixed(2)}</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Глобальные поля (для всех домов) */}
|
||
{globalFields.general && Object.keys(globalFields.general).map(key => {
|
||
const globalField = globalFields.general[key];
|
||
const localField = general.customFields?.[key] || globalField;
|
||
const isGlobal = true;
|
||
return (
|
||
<div key={`global-${key}`} className="relative group">
|
||
<PassportField
|
||
label={key}
|
||
value={localField.value}
|
||
section="general"
|
||
fieldKey={`customFields.${key}.value`}
|
||
type={localField.type as any}
|
||
files={localField.files}
|
||
onFilesChange={(files) => updateCustomField('general', key, localField.value, files)}
|
||
isCustomField={true}
|
||
customFieldKey={key}
|
||
isEditing={isEditing}
|
||
onValueChange={handleFieldValueChange}
|
||
/>
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => {
|
||
if (confirm(`Удалить глобальное поле "${key}" из всех домов?`)) {
|
||
storageService.deleteGlobalPassportField('general', key);
|
||
const sectionData = building.passport.general as any;
|
||
const customFields = { ...(sectionData.customFields || {}) };
|
||
delete customFields[key];
|
||
updatePassport('general', 'customFields', customFields);
|
||
}
|
||
}}
|
||
className="absolute top-1 right-1 p-1 text-red-500 hover:text-red-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Удалить глобальное поле"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Локальные кастомные поля (только для этого дома) */}
|
||
{general.customFields && Object.keys(general.customFields)
|
||
.filter(key => !(globalFields.general && globalFields.general[key]))
|
||
.map(key => {
|
||
const customField = general.customFields![key];
|
||
return (
|
||
<div key={key} className="relative group">
|
||
<PassportField
|
||
label={key}
|
||
value={customField.value}
|
||
section="general"
|
||
fieldKey={`customFields.${key}.value`}
|
||
type={customField.type as any}
|
||
files={customField.files}
|
||
onFilesChange={(files) => updateCustomField('general', key, customField.value, files)}
|
||
isCustomField={true}
|
||
customFieldKey={key}
|
||
isEditing={isEditing}
|
||
onValueChange={handleFieldValueChange}
|
||
/>
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => {
|
||
const sectionData = building.passport.general as any;
|
||
const customFields = { ...(sectionData.customFields || {}) };
|
||
delete customFields[key];
|
||
updatePassport('general', 'customFields', customFields);
|
||
}}
|
||
className="absolute top-0 right-0 p-1 text-red-500 hover:text-red-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Удалить поле"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => addCustomField('general')}
|
||
className="border-2 border-dashed border-slate-300 rounded-lg p-3 text-xs font-bold text-slate-500 hover:border-primary-400 hover:text-primary-600 transition-colors flex items-center justify-center gap-2"
|
||
>
|
||
<Plus className="w-4 h-4" /> Добавить поле
|
||
</button>
|
||
)}
|
||
</Section>
|
||
|
||
<Section title="2. Конструктивные элементы" icon={Layers}>
|
||
<PassportField label="Фундамент (Тип)" value={construction.foundationType} section="construction" fieldKey="foundationType" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Стены" value={construction.wallMaterial} section="construction" fieldKey="wallMaterial" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Кровля (Тип)" value={construction.roofType} section="construction" fieldKey="roofType" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Площадь кровли (м²)" value={construction.roofArea} section="construction" fieldKey="roofArea" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Фасад" value={construction.facadeType} section="construction" fieldKey="facadeType" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Утепление фасада" value={construction.facadeInsulation} section="construction" fieldKey="facadeInsulation" type="checkbox" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
|
||
{/* Новые поля */}
|
||
<PassportField label="Процент износа (%)" value={construction.wearPercent || ''} section="construction" fieldKey="wearPercent" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Подвал" value={construction.hasBasement || false} section="construction" fieldKey="hasBasement" type="checkbox" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Тех этаж" value={construction.hasTechFloor || false} section="construction" fieldKey="hasTechFloor" type="checkbox" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Чердак" value={construction.hasAttic || false} section="construction" fieldKey="hasAttic" type="checkbox" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Цоколь" value={construction.hasBasementFloor || false} section="construction" fieldKey="hasBasementFloor" type="checkbox" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
|
||
{/* Глобальные поля (для всех домов) */}
|
||
{globalFields.construction && Object.keys(globalFields.construction).map(key => {
|
||
const globalField = globalFields.construction[key];
|
||
const localField = construction.customFields?.[key] || globalField;
|
||
return (
|
||
<div key={`global-${key}`} className="relative group">
|
||
<PassportField
|
||
label={key}
|
||
value={localField.value}
|
||
section="construction"
|
||
fieldKey={`customFields.${key}.value`}
|
||
type={localField.type as any}
|
||
files={localField.files}
|
||
onFilesChange={(files) => updateCustomField('construction', key, localField.value, files)}
|
||
isCustomField={true}
|
||
customFieldKey={key}
|
||
isEditing={isEditing}
|
||
onValueChange={handleFieldValueChange}
|
||
/>
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => {
|
||
if (confirm(`Удалить глобальное поле "${key}" из всех домов?`)) {
|
||
storageService.deleteGlobalPassportField('construction', key);
|
||
const sectionData = building.passport.construction as any;
|
||
const customFields = { ...(sectionData.customFields || {}) };
|
||
delete customFields[key];
|
||
updatePassport('construction', 'customFields', customFields);
|
||
}
|
||
}}
|
||
className="absolute top-0 right-0 p-1 text-red-500 hover:text-red-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Удалить глобальное поле"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Локальные кастомные поля (только для этого дома) */}
|
||
{construction.customFields && Object.keys(construction.customFields)
|
||
.filter(key => !(globalFields.construction && globalFields.construction[key]))
|
||
.map(key => {
|
||
const customField = construction.customFields![key];
|
||
return (
|
||
<div key={key} className="relative group">
|
||
<PassportField
|
||
label={key}
|
||
value={customField.value}
|
||
section="construction"
|
||
fieldKey={`customFields.${key}.value`}
|
||
type={customField.type as any}
|
||
files={customField.files}
|
||
onFilesChange={(files) => updateCustomField('construction', key, customField.value, files)}
|
||
isCustomField={true}
|
||
customFieldKey={key}
|
||
isEditing={isEditing}
|
||
onValueChange={handleFieldValueChange}
|
||
/>
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => {
|
||
const sectionData = building.passport.construction as any;
|
||
const customFields = { ...(sectionData.customFields || {}) };
|
||
delete customFields[key];
|
||
updatePassport('construction', 'customFields', customFields);
|
||
}}
|
||
className="absolute top-0 right-0 p-1 text-red-500 hover:text-red-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Удалить поле"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => addCustomField('construction')}
|
||
className="border-2 border-dashed border-slate-300 rounded-lg p-3 text-xs font-bold text-slate-500 hover:border-primary-400 hover:text-primary-600 transition-colors flex items-center justify-center gap-2"
|
||
>
|
||
<Plus className="w-4 h-4" /> Добавить поле
|
||
</button>
|
||
)}
|
||
</Section>
|
||
|
||
<Section title="3. Инженерные системы" icon={Zap}>
|
||
<PassportField label="Отопление" value={engineering.heatingType} section="engineering" fieldKey="heatingType" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Износ отопления (%)" value={engineering.heatingWearPercent || ''} section="engineering" fieldKey="heatingWearPercent" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Наличие ИТП" value={engineering.hasITP} section="engineering" fieldKey="hasITP" type="checkbox" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="ХВС (Материал)" value={engineering.waterSupplyMaterial} section="engineering" fieldKey="waterSupplyMaterial" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Износ ХВС (%)" value={engineering.waterSupplyWearPercent || ''} section="engineering" fieldKey="waterSupplyWearPercent" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Канализация" value={engineering.sewerMaterial} section="engineering" fieldKey="sewerMaterial" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Износ канализации (%)" value={engineering.sewerWearPercent || ''} section="engineering" fieldKey="sewerWearPercent" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Газоснабжение" value={engineering.gasType} section="engineering" fieldKey="gasType" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Износ газоснабжения (%)" value={engineering.gasWearPercent || ''} section="engineering" fieldKey="gasWearPercent" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Эл. вводов" value={engineering.electricityEntries} section="engineering" fieldKey="electricityEntries" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Износ электроснабжения (%)" value={engineering.electricityWearPercent || ''} section="engineering" fieldKey="electricityWearPercent" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Вентиляция" value={engineering.ventilationType} section="engineering" fieldKey="ventilationType" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Износ вентиляции (%)" value={engineering.ventilationWearPercent || ''} section="engineering" fieldKey="ventilationWearPercent" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
|
||
{/* Глобальные поля (для всех домов) */}
|
||
{globalFields.engineering && Object.keys(globalFields.engineering).map(key => {
|
||
const globalField = globalFields.engineering[key];
|
||
const localField = engineering.customFields?.[key] || globalField;
|
||
return (
|
||
<div key={`global-${key}`} className="relative group">
|
||
<PassportField
|
||
label={key}
|
||
value={localField.value}
|
||
section="engineering"
|
||
fieldKey={`customFields.${key}.value`}
|
||
type={localField.type as any}
|
||
files={localField.files}
|
||
onFilesChange={(files) => updateCustomField('engineering', key, localField.value, files)}
|
||
isCustomField={true}
|
||
customFieldKey={key}
|
||
isEditing={isEditing}
|
||
onValueChange={handleFieldValueChange}
|
||
/>
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => {
|
||
if (confirm(`Удалить глобальное поле "${key}" из всех домов?`)) {
|
||
storageService.deleteGlobalPassportField('engineering', key);
|
||
const sectionData = building.passport.engineering as any;
|
||
const customFields = { ...(sectionData.customFields || {}) };
|
||
delete customFields[key];
|
||
updatePassport('engineering', 'customFields', customFields);
|
||
}
|
||
}}
|
||
className="absolute top-0 right-0 p-1 text-red-500 hover:text-red-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Удалить глобальное поле"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Локальные кастомные поля (только для этого дома) */}
|
||
{engineering.customFields && Object.keys(engineering.customFields)
|
||
.filter(key => !(globalFields.engineering && globalFields.engineering[key]))
|
||
.map(key => {
|
||
const customField = engineering.customFields![key];
|
||
return (
|
||
<div key={key} className="relative group">
|
||
<PassportField
|
||
label={key}
|
||
value={customField.value}
|
||
section="engineering"
|
||
fieldKey={`customFields.${key}.value`}
|
||
type={customField.type as any}
|
||
files={customField.files}
|
||
onFilesChange={(files) => updateCustomField('engineering', key, customField.value, files)}
|
||
isCustomField={true}
|
||
customFieldKey={key}
|
||
isEditing={isEditing}
|
||
onValueChange={handleFieldValueChange}
|
||
/>
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => {
|
||
const sectionData = building.passport.engineering as any;
|
||
const customFields = { ...(sectionData.customFields || {}) };
|
||
delete customFields[key];
|
||
updatePassport('engineering', 'customFields', customFields);
|
||
}}
|
||
className="absolute top-0 right-0 p-1 text-red-500 hover:text-red-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Удалить поле"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => addCustomField('engineering')}
|
||
className="border-2 border-dashed border-slate-300 rounded-lg p-3 text-xs font-bold text-slate-500 hover:border-primary-400 hover:text-primary-600 transition-colors flex items-center justify-center gap-2"
|
||
>
|
||
<Plus className="w-4 h-4" /> Добавить поле
|
||
</button>
|
||
)}
|
||
</Section>
|
||
|
||
<Section title="4. Приборы учета (ОДПУ)" icon={Gauge}>
|
||
{/* Список всех ПУ */}
|
||
{meters.filter(m => m.hasMeter).map((meter, idx) => {
|
||
let Icon = Gauge;
|
||
let colorClass = 'text-slate-500 bg-slate-50';
|
||
let label: string = meter.name || meter.resource;
|
||
let unit = meter.unit || 'ед.';
|
||
|
||
switch(meter.resource) {
|
||
case 'Heat': Icon = Flame; colorClass = 'text-red-500 bg-red-50'; label = meter.name || 'Отопление'; unit = meter.unit || 'Гкал'; break;
|
||
case 'Water': Icon = Droplets; colorClass = 'text-blue-500 bg-blue-50'; label = meter.name || 'Водоснабжение'; unit = meter.unit || 'м³'; break;
|
||
case 'Electricity': Icon = Zap; colorClass = 'text-amber-500 bg-amber-50'; label = meter.name || 'Электроснабжение'; unit = meter.unit || 'кВт⋅ч'; break;
|
||
case 'Gas': Icon = Gauge; colorClass = 'text-slate-500 bg-slate-50'; label = meter.name || 'Газоснабжение'; unit = meter.unit || 'м³'; break;
|
||
case 'Other': Icon = Gauge; colorClass = 'text-slate-500 bg-slate-50'; label = meter.name || 'Другой'; break;
|
||
}
|
||
|
||
const actualIdx = meters.findIndex(m => m === meter);
|
||
|
||
return (
|
||
<div
|
||
key={actualIdx}
|
||
onClick={() => setSelectedMeter({ meter, index: actualIdx })}
|
||
className="p-4 rounded-xl border border-slate-200 bg-white shadow-sm cursor-pointer hover:shadow-md transition-all"
|
||
>
|
||
<div className="flex justify-between items-start mb-3">
|
||
<div className="flex items-center gap-3 flex-1">
|
||
<div className={`p-2 rounded-lg ${colorClass}`}>
|
||
<Icon className="w-5 h-5"/>
|
||
</div>
|
||
<div className="flex-1">
|
||
<p className="font-bold text-slate-800 text-sm">{label}</p>
|
||
{meter.number && (
|
||
<p className="text-xs text-slate-500">№ {meter.number}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{isEditing && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (confirm('Удалить этот прибор учета?')) {
|
||
const newMeters = meters.filter((_, i) => i !== actualIdx);
|
||
updatePassport('meters' as any, 'meters' as any, newMeters);
|
||
}
|
||
}}
|
||
className="text-red-500 hover:text-red-700 p-1"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||
{meter.currentReading !== undefined && (
|
||
<div>
|
||
<span className="text-slate-500">Текущее показание:</span>
|
||
<p className="font-bold text-slate-800">{meter.currentReading.toLocaleString('ru-RU')} {unit}</p>
|
||
</div>
|
||
)}
|
||
{meter.nextVerification && (
|
||
<div>
|
||
<span className="text-slate-500">Поверка до:</span>
|
||
<p className={`font-bold ${new Date(meter.nextVerification) < new Date() ? 'text-red-600' : 'text-slate-800'}`}>
|
||
{new Date(meter.nextVerification).toLocaleDateString('ru-RU')}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{meters.filter(m => m.hasMeter).length === 0 && (
|
||
<p className="text-sm text-slate-400 text-center py-8">Нет добавленных приборов учета</p>
|
||
)}
|
||
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => setShowAddMeterModal(true)}
|
||
className="border-2 border-dashed border-slate-300 rounded-lg p-3 text-xs font-bold text-slate-500 hover:border-primary-400 hover:text-primary-600 transition-colors flex items-center justify-center gap-2"
|
||
>
|
||
<Plus className="w-4 h-4" /> Добавить ПУ
|
||
</button>
|
||
)}
|
||
</Section>
|
||
|
||
{/* Модальное окно создания ПУ */}
|
||
<AddMeterModal
|
||
isOpen={showAddMeterModal}
|
||
onClose={() => setShowAddMeterModal(false)}
|
||
onAdd={(newMeter) => {
|
||
const updatedMeters = [...meters, newMeter];
|
||
updatePassport('meters' as any, 'meters' as any, updatedMeters);
|
||
// Открываем карточку нового ПУ
|
||
setTimeout(() => {
|
||
setSelectedMeter({ meter: newMeter, index: meters.length });
|
||
}, 100);
|
||
}}
|
||
/>
|
||
|
||
{/* Модальное окно карточки ПУ */}
|
||
{selectedMeter && (
|
||
<MeterCardModal
|
||
meter={selectedMeter.meter}
|
||
meterIndex={selectedMeter.index}
|
||
isEditing={isEditing}
|
||
onClose={() => setSelectedMeter(null)}
|
||
onUpdate={(index, field, value) => {
|
||
updatePassportArray('meters', index, field, value);
|
||
}}
|
||
onAddReading={(index, reading) => {
|
||
const meter = meters[index];
|
||
const updatedReadings = [...(meter.readings || []), reading];
|
||
updatePassportArray('meters', index, 'readings', updatedReadings);
|
||
// Обновляем текущее показание
|
||
updatePassportArray('meters', index, 'currentReading', reading.value);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
<Section title="5. Лифтовое хозяйство" icon={ArrowUpFromLine}>
|
||
{lifts.map((lift, idx) => (
|
||
<div key={idx} className="p-4 border border-slate-200 rounded-xl bg-white shadow-sm">
|
||
<div className="flex justify-between items-start mb-3">
|
||
<p className="font-bold text-slate-800 text-sm">Лифт №{idx + 1}</p>
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => {
|
||
const newLifts = lifts.filter((_, i) => i !== idx);
|
||
updatePassport('lifts' as any, 'lifts' as any, newLifts);
|
||
}}
|
||
className="text-red-500 hover:text-red-700"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||
<div>
|
||
<label className="text-slate-500 block mb-1">Тип:</label>
|
||
<EditableField value={lift.type || ''} onChange={(v) => updatePassportArray('lifts', idx, 'type', v)} isEditing={isEditing} className="text-right w-full"/>
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500 block mb-1">Год установки:</label>
|
||
<EditableField value={lift.installYear || ''} onChange={(v) => updatePassportArray('lifts', idx, 'installYear', Number(v))} isEditing={isEditing} type="number" className="text-right w-full"/>
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500 block mb-1">Грузоподъемность (кг):</label>
|
||
<EditableField value={lift.capacity || ''} onChange={(v) => updatePassportArray('lifts', idx, 'capacity', Number(v))} isEditing={isEditing} type="number" className="text-right w-full"/>
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500 block mb-1">Скорость (м/с):</label>
|
||
<EditableField value={lift.speed || ''} onChange={(v) => updatePassportArray('lifts', idx, 'speed', Number(v))} isEditing={isEditing} type="number" className="text-right w-full"/>
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500 block mb-1">Заводской номер:</label>
|
||
<EditableField value={lift.factoryNumber || ''} onChange={(v) => updatePassportArray('lifts', idx, 'factoryNumber', v)} isEditing={isEditing} className="text-right w-full"/>
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500 block mb-1">Производитель:</label>
|
||
<EditableField value={lift.manufacturer || ''} onChange={(v) => updatePassportArray('lifts', idx, 'manufacturer', v)} isEditing={isEditing} className="text-right w-full"/>
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500 block mb-1">Износ (%):</label>
|
||
<EditableField value={lift.wearPercent || ''} onChange={(v) => updatePassportArray('lifts', idx, 'wearPercent', Number(v))} isEditing={isEditing} type="number" className="text-right w-full"/>
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500 block mb-1">Статус:</label>
|
||
<EditableField value={lift.status || ''} onChange={(v) => updatePassportArray('lifts', idx, 'status', v)} isEditing={isEditing} className="text-right w-full"/>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<label className="text-slate-500 block mb-1">Последнее обслуживание:</label>
|
||
<EditableField value={lift.lastMaintenanceDate || ''} onChange={(v) => updatePassportArray('lifts', idx, 'lastMaintenanceDate', v)} isEditing={isEditing} type="date" className="text-right w-full"/>
|
||
</div>
|
||
<div className="col-span-2">
|
||
<label className="text-slate-500 block mb-1">Следующее обслуживание:</label>
|
||
<EditableField value={lift.nextMaintenanceDate || ''} onChange={(v) => updatePassportArray('lifts', idx, 'nextMaintenanceDate', v)} isEditing={isEditing} type="date" className="text-right w-full"/>
|
||
</div>
|
||
{isEditing && (
|
||
<div className="col-span-2">
|
||
<label className="text-slate-500 block mb-1">Документы:</label>
|
||
<input
|
||
type="file"
|
||
multiple
|
||
onChange={(e) => {
|
||
if (!e.target.files) return;
|
||
const fileList = e.target.files;
|
||
const newFiles = Array.from(fileList).map((file: File) => URL.createObjectURL(file as Blob));
|
||
const updatedLift = { ...lift, documents: [...(lift.documents || []), ...newFiles] };
|
||
updatePassportArray('lifts', idx, 'documents', updatedLift.documents);
|
||
}}
|
||
className="text-xs"
|
||
/>
|
||
{lift.documents && lift.documents.length > 0 && (
|
||
<div className="mt-2 space-y-1">
|
||
{lift.documents.map((doc, docIdx) => (
|
||
<div key={docIdx} className="flex items-center gap-2 text-xs text-slate-600 bg-slate-50 p-1.5 rounded">
|
||
<File className="w-3 h-3" />
|
||
<span className="flex-1 truncate">Документ {docIdx + 1}</span>
|
||
<button
|
||
onClick={() => {
|
||
const updatedDocuments = lift.documents!.filter((_, i) => i !== docIdx);
|
||
updatePassportArray('lifts', idx, 'documents', updatedDocuments);
|
||
}}
|
||
className="text-red-500 hover:text-red-700"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{!isEditing && lift.documents && lift.documents.length > 0 && (
|
||
<div className="col-span-2 space-y-1">
|
||
{lift.documents.map((doc, docIdx) => (
|
||
<a key={docIdx} href={doc} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-xs text-primary-600 hover:text-primary-700">
|
||
<File className="w-3 h-3" />
|
||
<span>Документ {docIdx + 1}</span>
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => {
|
||
const newLift = {
|
||
count: 1,
|
||
type: '',
|
||
capacity: 0,
|
||
speed: 0,
|
||
installYear: new Date().getFullYear(),
|
||
factoryNumber: ''
|
||
};
|
||
updatePassport('lifts' as any, 'lifts' as any, [...lifts, newLift]);
|
||
}}
|
||
className="border-2 border-dashed border-slate-300 rounded-lg p-4 text-xs font-bold text-slate-500 hover:border-primary-400 hover:text-primary-600 transition-colors flex items-center justify-center gap-2"
|
||
>
|
||
<Plus className="w-4 h-4" /> Добавить лифт
|
||
</button>
|
||
)}
|
||
</Section>
|
||
|
||
<Section title="6. Земля и благоустройство" icon={Trees}>
|
||
<PassportField label="Площадь участка (м²)" value={land.area} section="land" fieldKey="area" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Детская площадка" value={land.hasPlayground} section="land" fieldKey="hasPlayground" type="checkbox" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Спортивная площадка" value={land.hasSportsGround} section="land" fieldKey="hasSportsGround" type="checkbox" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Парковка" value={land.hasParking} section="land" fieldKey="hasParking" type="checkbox" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Ограждение" value={land.hasFencing} section="land" fieldKey="hasFencing" type="checkbox" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Площадка для контейнеров" value={land.hasContainerSite} section="land" fieldKey="hasContainerSite" type="checkbox" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
|
||
{/* Глобальные поля (для всех домов) */}
|
||
{globalFields.land && Object.keys(globalFields.land).map(key => {
|
||
const globalField = globalFields.land[key];
|
||
const localField = land.customFields?.[key] || globalField;
|
||
return (
|
||
<div key={`global-${key}`} className="relative group">
|
||
<PassportField
|
||
label={key}
|
||
value={localField.value}
|
||
section="land"
|
||
fieldKey={`customFields.${key}.value`}
|
||
type={localField.type as any}
|
||
files={localField.files}
|
||
onFilesChange={(files) => updateCustomField('land', key, localField.value, files)}
|
||
isCustomField={true}
|
||
customFieldKey={key}
|
||
isEditing={isEditing}
|
||
onValueChange={handleFieldValueChange}
|
||
/>
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => {
|
||
if (confirm(`Удалить глобальное поле "${key}" из всех домов?`)) {
|
||
storageService.deleteGlobalPassportField('land', key);
|
||
const sectionData = building.passport.land as any;
|
||
const customFields = { ...(sectionData.customFields || {}) };
|
||
delete customFields[key];
|
||
updatePassport('land', 'customFields', customFields);
|
||
}
|
||
}}
|
||
className="absolute top-0 right-0 p-1 text-red-500 hover:text-red-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Удалить глобальное поле"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Локальные кастомные поля (только для этого дома) */}
|
||
{land.customFields && Object.keys(land.customFields)
|
||
.filter(key => !(globalFields.land && globalFields.land[key]))
|
||
.map(key => {
|
||
const customField = land.customFields![key];
|
||
return (
|
||
<div key={key} className="relative group">
|
||
<PassportField
|
||
label={key}
|
||
value={customField.value}
|
||
section="land"
|
||
fieldKey={`customFields.${key}.value`}
|
||
type={customField.type as any}
|
||
files={customField.files}
|
||
onFilesChange={(files) => updateCustomField('land', key, customField.value, files)}
|
||
isCustomField={true}
|
||
customFieldKey={key}
|
||
isEditing={isEditing}
|
||
onValueChange={handleFieldValueChange}
|
||
/>
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => {
|
||
const sectionData = building.passport.land as any;
|
||
const customFields = { ...(sectionData.customFields || {}) };
|
||
delete customFields[key];
|
||
updatePassport('land', 'customFields', customFields);
|
||
}}
|
||
className="absolute top-0 right-0 p-1 text-red-500 hover:text-red-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Удалить поле"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => addCustomField('land')}
|
||
className="border-2 border-dashed border-slate-300 rounded-lg p-3 text-xs font-bold text-slate-500 hover:border-primary-400 hover:text-primary-600 transition-colors flex items-center justify-center gap-2"
|
||
>
|
||
<Plus className="w-4 h-4" /> Добавить поле
|
||
</button>
|
||
)}
|
||
</Section>
|
||
|
||
<Section title="7. Управление" icon={Briefcase}>
|
||
<PassportField label="Дата договора" value={management.contractDate} section="management" fieldKey="contractDate" type="date" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Номер договора" value={management.contractNumber} section="management" fieldKey="contractNumber" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Тариф (содержание) ₽/м²" value={management.tariffMaintenance} section="management" fieldKey="tariffMaintenance" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
<PassportField label="Резервный фонд (%)" value={management.reserveFund || ''} section="management" fieldKey="reserveFund" type="number" isEditing={isEditing} onValueChange={handleFieldValueChange} />
|
||
|
||
{/* Глобальные поля (для всех домов) */}
|
||
{globalFields.management && Object.keys(globalFields.management).map(key => {
|
||
const globalField = globalFields.management[key];
|
||
const localField = management.customFields?.[key] || globalField;
|
||
return (
|
||
<div key={`global-${key}`} className="relative group">
|
||
<PassportField
|
||
label={key}
|
||
value={localField.value}
|
||
section="management"
|
||
fieldKey={`customFields.${key}.value`}
|
||
type={localField.type as any}
|
||
files={localField.files}
|
||
onFilesChange={(files) => updateCustomField('management', key, localField.value, files)}
|
||
isCustomField={true}
|
||
customFieldKey={key}
|
||
isEditing={isEditing}
|
||
onValueChange={handleFieldValueChange}
|
||
/>
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => {
|
||
if (confirm(`Удалить глобальное поле "${key}" из всех домов?`)) {
|
||
storageService.deleteGlobalPassportField('management', key);
|
||
const sectionData = building.passport.management as any;
|
||
const customFields = { ...(sectionData.customFields || {}) };
|
||
delete customFields[key];
|
||
updatePassport('management', 'customFields', customFields);
|
||
}
|
||
}}
|
||
className="absolute top-0 right-0 p-1 text-red-500 hover:text-red-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Удалить глобальное поле"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Локальные кастомные поля (только для этого дома) */}
|
||
{management.customFields && Object.keys(management.customFields)
|
||
.filter(key => !(globalFields.management && globalFields.management[key]))
|
||
.map(key => {
|
||
const customField = management.customFields![key];
|
||
return (
|
||
<div key={key} className="relative group">
|
||
<PassportField
|
||
label={key}
|
||
value={customField.value}
|
||
section="management"
|
||
fieldKey={`customFields.${key}.value`}
|
||
type={customField.type as any}
|
||
files={customField.files}
|
||
onFilesChange={(files) => updateCustomField('management', key, customField.value, files)}
|
||
isCustomField={true}
|
||
customFieldKey={key}
|
||
isEditing={isEditing}
|
||
onValueChange={handleFieldValueChange}
|
||
/>
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => {
|
||
const sectionData = building.passport.management as any;
|
||
const customFields = { ...(sectionData.customFields || {}) };
|
||
delete customFields[key];
|
||
updatePassport('management', 'customFields', customFields);
|
||
}}
|
||
className="absolute top-0 right-0 p-1 text-red-500 hover:text-red-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
title="Удалить поле"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => addCustomField('management')}
|
||
className="border-2 border-dashed border-slate-300 rounded-lg p-3 text-xs font-bold text-slate-500 hover:border-primary-400 hover:text-primary-600 transition-colors flex items-center justify-center gap-2"
|
||
>
|
||
<Plus className="w-4 h-4" /> Добавить поле
|
||
</button>
|
||
)}
|
||
</Section>
|
||
|
||
{/* NEW SECTION: SERVICE CONTRACTS (RSO, INTERNET, ETC) */}
|
||
<Section title="8. Договоры с РСО и подрядчиками" icon={ShieldCheck} noGrid>
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{serviceContracts.map((contract) => {
|
||
const SvcIcon = getServiceIcon(contract.serviceType);
|
||
const nextExp = contract.expiryDate ? new Date(contract.expiryDate) : null;
|
||
const isExpiring = nextExp && nextExp < new Date(new Date().setMonth(new Date().getMonth() + 1));
|
||
|
||
return (
|
||
<div key={contract.id} className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm group hover:border-primary-300 transition-colors relative">
|
||
<div className="flex justify-between items-start mb-3">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-2 bg-slate-100 text-slate-500 rounded-lg group-hover:bg-primary-50 group-hover:text-primary-600 transition-colors">
|
||
<SvcIcon className="w-5 h-5"/>
|
||
</div>
|
||
<div>
|
||
<EditableField
|
||
value={contract.serviceType}
|
||
onChange={(v) => handleUpdateContract(contract.id, 'serviceType', v)}
|
||
isEditing={isEditing}
|
||
className="text-sm font-bold text-slate-800"
|
||
/>
|
||
<div className="flex items-center gap-1 mt-0.5">
|
||
<span className="text-[10px] text-slate-400 font-bold">№</span>
|
||
<EditableField
|
||
value={contract.contractNumber}
|
||
onChange={(v) => handleUpdateContract(contract.id, 'contractNumber', v)}
|
||
isEditing={isEditing}
|
||
className="text-[10px] font-bold text-slate-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{isEditing && (
|
||
<button
|
||
onClick={() => handleRemoveContract(contract.id)}
|
||
className="p-1 text-slate-300 hover:text-red-500 transition-colors"
|
||
>
|
||
<Trash2 className="w-4 h-4"/>
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2 mt-2 pt-3 border-t border-slate-100">
|
||
<div>
|
||
<label className="text-[9px] text-slate-400 font-bold uppercase block">Контрагент</label>
|
||
<EditableField
|
||
value={contract.providerName}
|
||
onChange={(v) => handleUpdateContract(contract.id, 'providerName', v)}
|
||
isEditing={isEditing}
|
||
className="text-xs font-bold text-slate-700"
|
||
/>
|
||
</div>
|
||
<div className="flex justify-between items-end">
|
||
<div>
|
||
<label className="text-[9px] text-slate-400 font-bold uppercase block">Дата заключения</label>
|
||
<EditableField
|
||
value={contract.contractDate}
|
||
onChange={(v) => handleUpdateContract(contract.id, 'contractDate', v)}
|
||
isEditing={isEditing}
|
||
type="date"
|
||
className="text-xs font-medium text-slate-600"
|
||
/>
|
||
</div>
|
||
{contract.expiryDate && (
|
||
<div className="text-right">
|
||
<label className="text-[9px] text-slate-400 font-bold uppercase block">Действует до</label>
|
||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${isExpiring ? 'bg-red-50 text-red-600' : 'bg-emerald-50 text-emerald-600'}`}>
|
||
{contract.expiryDate}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{isEditing && (
|
||
<button
|
||
onClick={handleAddContract}
|
||
className="border-2 border-dashed border-slate-200 rounded-xl p-6 flex flex-col items-center justify-center text-slate-400 hover:border-primary-400 hover:text-primary-500 transition-all bg-slate-50/50"
|
||
>
|
||
<Plus className="w-6 h-6 mb-2"/>
|
||
<span className="text-xs font-bold uppercase tracking-wider">Добавить договор</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
</div>
|
||
);
|
||
};
|