Files
mkd/components/building/AccountsView.tsx

2653 lines
143 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { Building, PersonalAccount, AccountMeter, MeterReading, Debtor, ResidentProfile, RegisteredPerson } from '../../types';
import { backendApi } from '../../services/apiClient';
import { REFRESH_EVENTS } from '../../constants/refreshEvents';
import { ArrowLeft, Building2, User, Users, FileCheck, CheckCircle2, X, Droplets, Flame, Zap, Gauge, ChevronRight, BarChart3, History, Search, AlertCircle, Phone, Calendar, Mail, MessageSquare, Edit2, Save, AlertTriangle, Heart, Smile, Frown, Shield, FileText, Plus, Trash2, UserPlus, Upload, File, TrendingUp, Clock } from 'lucide-react';
// Компонент для редактирования номера лицевого счета с сохранением курсора
const AccountNumberInput: React.FC<{ value: string; onChange: (value: string) => void }> = ({ value, onChange }) => {
const [localValue, setLocalValue] = useState(value);
const inputRef = useRef<HTMLInputElement>(null);
const wasFocused = useRef(false);
useEffect(() => {
setLocalValue(value);
}, [value]);
useEffect(() => {
if (wasFocused.current && inputRef.current) {
const cursorPosition = inputRef.current.selectionStart || 0;
inputRef.current.focus();
inputRef.current.setSelectionRange(cursorPosition, cursorPosition);
wasFocused.current = false;
}
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
wasFocused.current = true;
onChange(newValue);
};
const handleFocus = () => {
wasFocused.current = true;
};
return (
<input
ref={inputRef}
type="text"
value={localValue}
onChange={handleChange}
onFocus={handleFocus}
className="text-xs text-slate-700 font-medium bg-transparent border-b-2 border-slate-300 focus:border-primary-500 outline-none px-1 rounded-t transition-colors min-w-[100px]"
placeholder="Номер лицевого счета"
/>
);
};
// Компонент для редактирования числовых полей с сохранением курсора
const EditableNumberInput: React.FC<{
value: number | string;
onChange: (value: number) => void;
className?: string;
placeholder?: string;
step?: string;
}> = ({ value, onChange, className = "", placeholder, step }) => {
const [localValue, setLocalValue] = useState(String(value || ''));
const inputRef = useRef<HTMLInputElement>(null);
const wasFocused = useRef(false);
useEffect(() => {
setLocalValue(String(value || ''));
}, [value]);
useEffect(() => {
if (wasFocused.current && inputRef.current) {
inputRef.current.focus();
// setSelectionRange не поддерживается для type="number"
wasFocused.current = false;
}
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
wasFocused.current = true;
const numValue = step ? parseFloat(newValue) || 0 : parseInt(newValue) || 0;
setTimeout(() => {
onChange(numValue);
}, 0);
};
const handleFocus = () => {
wasFocused.current = true;
};
return (
<input
ref={inputRef}
type="number"
step={step}
value={localValue}
onChange={handleChange}
onFocus={handleFocus}
className={`bg-transparent border-b-2 border-slate-300 focus:border-primary-500 outline-none ${className}`}
placeholder={placeholder}
/>
);
};
// Компонент для редактирования текстовых полей с сохранением курсора
const EditableTextInput: React.FC<{
value: string;
onChange: (value: string) => void;
className?: string;
placeholder?: string;
}> = ({ value, onChange, className = "", placeholder }) => {
const [localValue, setLocalValue] = useState(value || '');
const inputRef = useRef<HTMLInputElement>(null);
const wasFocused = useRef(false);
useEffect(() => {
setLocalValue(value || '');
}, [value]);
useEffect(() => {
if (wasFocused.current && inputRef.current) {
const cursorPosition = inputRef.current.selectionStart || 0;
inputRef.current.focus();
inputRef.current.setSelectionRange(cursorPosition, cursorPosition);
wasFocused.current = false;
}
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
wasFocused.current = true;
setTimeout(() => {
onChange(newValue);
}, 0);
};
const handleFocus = () => {
wasFocused.current = true;
};
return (
<input
ref={inputRef}
type="text"
value={localValue}
onChange={handleChange}
onFocus={handleFocus}
className={`bg-transparent border-b-2 border-slate-300 focus:border-primary-500 outline-none ${className}`}
placeholder={placeholder}
/>
);
};
// Модальное окно для создания нового индивидуального ПУ
const AddAccountMeterModal: React.FC<{
isOpen: boolean;
account: PersonalAccount;
onClose: () => void;
onAdd: (meter: AccountMeter) => void;
}> = ({ isOpen, account, onClose, onAdd }) => {
const [type, setType] = useState<'ХВС' | 'ГВС' | 'Э/Э' | 'Газ' | 'Other'>('ХВС');
const [name, setName] = useState('');
const [number, setNumber] = useState('');
const [make, setMake] = 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> = {
'ХВС': 'м³',
'ГВС': 'м³',
'Э/Э': 'кВт⋅ч',
'Газ': 'м³',
'Other': ''
};
const defaultNames: Record<string, string> = {
'ХВС': 'Холодная вода',
'ГВС': 'Горячая вода',
'Э/Э': 'Электричество',
'Газ': 'Газоснабжение',
'Other': ''
};
const handleTypeChange = (newType: typeof type) => {
setType(newType);
if (!name || name === defaultNames[type]) {
setName(defaultNames[newType] || '');
}
if (!unit || unit === defaultUnits[type]) {
setUnit(defaultUnits[newType] || '');
}
};
const handleSubmit = () => {
if (!number.trim() || !make.trim()) {
alert('Заполните номер и модель прибора');
return;
}
const newMeter: AccountMeter = {
id: `meter-${Date.now()}`,
type,
make: make.trim(),
number: number.trim(),
name: name.trim() || defaultNames[type] || undefined,
manufacturer: manufacturer.trim() || undefined,
unit: unit.trim() || defaultUnits[type] || undefined,
installDate: installDate || undefined,
lastVerification: lastVerification || new Date().toISOString().split('T')[0],
nextVerification: nextVerification || new Date(new Date().setFullYear(new Date().getFullYear() + 4)).toISOString().split('T')[0],
currentReading: currentReading ? parseFloat(currentReading) : undefined,
readings: currentReading ? [{
date: new Date().toISOString().split('T')[0],
value: parseFloat(currentReading),
consumption: 0,
source: 'manual'
}] : [],
documents: []
};
onAdd(newMeter);
// Сброс формы
setType('ХВС');
setName('');
setNumber('');
setMake('');
setManufacturer('');
setUnit('');
setInstallDate('');
setLastVerification('');
setNextVerification('');
setCurrentReading('');
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={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">
<div>
<h3 className="text-xl font-bold text-slate-800">Добавить прибор учета</h3>
<p className="text-sm text-slate-500 mt-1">Кв. {account.apartmentNumber}</p>
</div>
<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={type}
onChange={(e) => handleTypeChange(e.target.value as typeof type)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="ХВС">ХВС (Холодная вода)</option>
<option value="ГВС">ГВС (Горячая вода)</option>
<option value="Э/Э">Э/Э (Электричество)</option>
<option value="Газ">Газоснабжение</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[type] || 'Введите название'}
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={make}
onChange={(e) => setMake(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[type] || 'Единица измерения'}
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>
);
};
// Sub-component: Add Reading Modal
const AddReadingModal: React.FC<{
meter: AccountMeter;
onClose: () => void;
onAdd: (reading: MeterReading) => void;
}> = ({ meter, onClose, onAdd }) => {
const [formData, setFormData] = useState({
date: new Date().toISOString().split('T')[0],
value: '',
consumption: '',
source: 'manual' as 'app' | 'manual' | 'iot',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.value || !formData.consumption) {
alert('Заполните показание и расход');
return;
}
const reading: MeterReading = {
date: formData.date,
value: parseFloat(formData.value),
consumption: parseFloat(formData.consumption),
source: formData.source,
};
onAdd(reading);
onClose();
};
return (
<div className="fixed inset-0 z-[110] 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-md shadow-2xl animate-slide-up" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-200">
<div className="flex justify-between items-center">
<h3 className="text-lg 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>
<p className="text-xs text-slate-500 mt-1">{meter.type} {meter.number}</p>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Дата *</label>
<input
type="date"
required
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
className="w-full p-3 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="number"
required
step="0.01"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
placeholder="Текущее показание"
className="w-full p-3 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="number"
required
step="0.01"
value={formData.consumption}
onChange={(e) => setFormData({ ...formData, consumption: e.target.value })}
placeholder="Расход за период"
className="w-full p-3 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.source}
onChange={(e) => setFormData({ ...formData, source: e.target.value as any })}
className="w-full p-3 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="manual">Офис (ручной ввод)</option>
<option value="app">Приложение</option>
<option value="iot">IoT (автоматически)</option>
</select>
</div>
<div className="flex gap-3 pt-4 border-t border-slate-200">
<button
type="submit"
className="flex-1 bg-primary-600 text-white px-6 py-3 rounded-xl font-bold text-sm uppercase flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 hover:bg-primary-700 transition-all"
>
<Plus className="w-4 h-4"/> Добавить
</button>
<button
type="button"
onClick={onClose}
className="px-6 py-3 bg-slate-100 text-slate-600 rounded-xl font-bold text-sm hover:bg-slate-200 transition-all"
>
Отмена
</button>
</div>
</form>
</div>
</div>
);
};
// Sub-component: Meter Modal
const MeterHistoryModal: React.FC<{
meter: AccountMeter,
onClose: () => void,
onAddReading?: (reading: MeterReading) => void
}> = ({ meter, onClose, onAddReading }) => {
const [showAddModal, setShowAddModal] = useState(false);
const handleAddReading = (reading: MeterReading) => {
if (onAddReading) {
onAddReading(reading);
}
};
// Сортируем показания по дате (от новых к старым)
const sortedReadings = useMemo(() => {
return [...meter.readings].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [meter.readings]);
// Статистика по показаниям
const stats = useMemo(() => {
if (!sortedReadings || sortedReadings.length === 0) {
return {
total: 0,
average: 0,
max: 0,
min: 0,
count: 0,
period: { start: null, end: null }
};
}
const readingsWithConsumption = sortedReadings.filter(r => r.consumption !== undefined && r.consumption > 0);
if (readingsWithConsumption.length === 0) {
return {
total: 0,
average: 0,
max: 0,
min: 0,
count: sortedReadings.length,
period: sortedReadings.length > 0 ? {
start: new Date(Math.min(...sortedReadings.map(r => new Date(r.date).getTime()))),
end: new Date(Math.max(...sortedReadings.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: sortedReadings.length,
period: {
start: new Date(Math.min(...dates.map(d => d.getTime()))),
end: new Date(Math.max(...dates.map(d => d.getTime())))
}
};
}, [sortedReadings]);
// Calculate max value for simple graph scaling
const maxVal = Math.max(...sortedReadings.map(r => r.consumption || 0), 1);
const unit = meter.unit || (meter.type === 'Э/Э' ? 'кВт⋅ч' : 'м³');
// Config based on type
let colorClass = 'bg-slate-500';
let textClass = 'text-slate-600';
let bgClass = 'bg-slate-50';
switch(meter.type) {
case 'ГВС':
colorClass = 'bg-red-500';
textClass = 'text-red-600';
bgClass = 'bg-red-50';
break;
case 'ХВС':
colorClass = 'bg-blue-500';
textClass = 'text-blue-600';
bgClass = 'bg-blue-50';
break;
case 'Э/Э':
colorClass = 'bg-amber-500';
textClass = 'text-amber-600';
bgClass = 'bg-amber-50';
break;
}
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-[85vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex justify-between items-center mb-6">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold text-slate-800">{meter.type} Счетчик</h3>
<span className={`text-[10px] px-2 py-0.5 rounded font-bold uppercase ${bgClass} ${textClass}`}>Активен</span>
</div>
<p className="text-xs text-slate-500 mt-1"> {meter.number} {meter.make}</p>
</div>
<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="grid grid-cols-2 gap-4 mb-6">
<div className="p-3 bg-white border border-slate-200 rounded-lg">
<span className="text-[10px] text-slate-400 uppercase font-bold">Текущее показание</span>
<p className="text-sm font-bold text-slate-800">{meter.currentReading?.toLocaleString('ru-RU', { maximumFractionDigits: 2 }) || (sortedReadings.length > 0 ? sortedReadings[0].value.toLocaleString('ru-RU', { maximumFractionDigits: 2 }) : '-')} {unit}</p>
</div>
<div className="p-3 bg-white border border-slate-200 rounded-lg">
<span className="text-[10px] text-slate-400 uppercase font-bold">Посл. поверка</span>
<p className="text-sm font-bold text-slate-800">{meter.lastVerification || '-'}</p>
</div>
<div className="p-3 bg-white border border-slate-200 rounded-lg">
<span className="text-[10px] text-slate-400 uppercase font-bold">След. поверка</span>
<p className={`text-sm font-bold ${meter.nextVerification && new Date(meter.nextVerification) < new Date() ? 'text-red-600' : 'text-slate-800'}`}>
{meter.nextVerification || '-'}
</p>
</div>
{meter.manufacturer && (
<div className="p-3 bg-white border border-slate-200 rounded-lg">
<span className="text-[10px] text-slate-400 uppercase font-bold">Производитель</span>
<p className="text-sm font-bold text-slate-800">{meter.manufacturer}</p>
</div>
)}
</div>
{/* Статистика */}
{stats.count > 0 && (
<div className="mb-6 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>
)}
{/* Graph */}
{sortedReadings.length > 0 && (() => {
const readingsWithConsumption = sortedReadings.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="mb-6 bg-slate-50 p-4 rounded-xl border border-slate-100">
<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-40 flex items-end justify-between gap-1 pb-1 border-b border-slate-200/50">
{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="absolute bottom-full mb-1 bg-slate-800 text-white text-[9px] py-0.5 px-1.5 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10 pointer-events-none">
{new Date(r.date).toLocaleDateString('ru-RU')}: {(r.consumption || 0).toLocaleString('ru-RU')} {unit}
</div>
<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}%` }}
></div>
</div>
))}
</div>
<div className="flex justify-between mt-2 text-[9px] text-slate-400 px-1">
<span>Начало</span>
<span>Сейчас</span>
</div>
</div>
) : null;
})()}
{/* History List */}
<div>
<div className="flex justify-between items-center mb-3">
<h4 className="font-bold text-xs text-slate-500 uppercase flex items-center gap-1">
<History className="w-4 h-4"/> История показаний
</h4>
{onAddReading && (
<button
onClick={() => setShowAddModal(true)}
className="p-1.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
title="Добавить показание"
>
<Plus className="w-4 h-4"/>
</button>
)}
</div>
{showAddModal && onAddReading && (
<AddReadingModal
meter={meter}
onClose={() => setShowAddModal(false)}
onAdd={handleAddReading}
/>
)}
<div className="border border-slate-200 rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-slate-50 text-slate-500 font-bold border-b border-slate-200 text-xs">
<tr>
<th className="p-3">Дата</th>
<th className="p-3 text-right">Показание</th>
<th className="p-3 text-right">Расход</th>
<th className="p-3 text-right">Источник</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{sortedReadings.length > 0 ? sortedReadings.map((r, i) => (
<tr key={i} className="hover:bg-slate-50">
<td className="p-3 text-xs text-slate-600 font-medium">{new Date(r.date).toLocaleDateString('ru-RU')}</td>
<td className="p-3 text-right font-medium text-slate-800">{r.value.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} {unit}</td>
<td className={`p-3 text-right font-bold ${textClass} flex items-center justify-end gap-1`}>
{r.consumption !== undefined && r.consumption > 0 && (
<>
<TrendingUp className="w-3 h-3" />
{r.consumption.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} {unit}
</>
)}
</td>
<td className="p-3 text-right">
<span className={`text-[9px] px-1.5 py-0.5 rounded uppercase font-bold ${r.source === 'app' ? 'bg-indigo-50 text-indigo-600' : r.source === 'manual' ? 'bg-slate-100 text-slate-500' : 'bg-emerald-50 text-emerald-600'}`}>
{r.source === 'app' ? 'Прил.' : r.source === 'manual' ? 'Офис' : 'IoT'}
</span>
</td>
</tr>
)) : (
<tr>
<td colSpan={4} className="p-4 text-center text-xs text-slate-400">
Нет данных о показаниях. Добавьте первое показание.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
);
};
// Sub-component: Resident Profile Modal
const ResidentProfileModal: React.FC<{
profile: ResidentProfile | undefined;
personName: string;
personPhone?: string;
onClose: () => void;
onSave: (profile: ResidentProfile) => void;
}> = ({ profile, personName, personPhone, onClose, onSave }) => {
const [formData, setFormData] = useState<ResidentProfile>(profile || {
sentiment: 'neutral',
notes: '',
});
const sentimentConfig = {
negative: { label: 'Негативен', icon: Frown, color: 'text-red-600 bg-red-50 border-red-200' },
toxic: { label: 'Токсичен', icon: AlertTriangle, color: 'text-orange-600 bg-orange-50 border-orange-200' },
positive: { label: 'Позитивен', icon: Smile, color: 'text-emerald-600 bg-emerald-50 border-emerald-200' },
loyal: { label: 'Лоялен', icon: Heart, color: 'text-blue-600 bg-blue-50 border-blue-200' },
neutral: { label: 'Нейтрален', icon: User, color: 'text-slate-600 bg-slate-50 border-slate-200' },
};
const handleSave = () => {
onSave(formData);
onClose();
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={onClose}>
<div className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl animate-slide-up" onClick={e => e.stopPropagation()}>
<div className="sticky top-0 bg-white border-b border-slate-200 p-4 sm:p-6 rounded-t-2xl z-10">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center">
<User className="w-5 h-5 text-slate-400"/>
</div>
<div>
<h3 className="text-lg font-bold text-slate-800">{personName}</h3>
{personPhone && (
<p className="text-xs text-slate-500 flex items-center gap-1 mt-0.5">
<Phone className="w-3 h-3"/>
{personPhone}
</p>
)}
</div>
</div>
<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="p-4 sm:p-6 space-y-4">
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-3">Настроение клиента</label>
<div className="grid grid-cols-5 gap-2">
{(Object.keys(sentimentConfig) as Array<keyof typeof sentimentConfig>).map(key => {
const config = sentimentConfig[key];
const Icon = config.icon;
return (
<button
key={key}
onClick={() => setFormData({ ...formData, sentiment: key })}
className={`p-3 rounded-xl border-2 transition-all flex flex-col items-center gap-2 ${
formData.sentiment === key
? `${config.color} border-current`
: 'bg-slate-50 border-slate-200 hover:border-slate-300'
}`}
>
<Icon className="w-5 h-5"/>
<span className="text-[10px] font-bold text-center leading-tight">{config.label}</span>
</button>
);
})}
</div>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">День рождения</label>
<input
type="date"
value={formData.birthday || ''}
onChange={(e) => setFormData({ ...formData, birthday: e.target.value })}
className="w-full p-3 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">Email</label>
<input
type="email"
value={formData.email || ''}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="email@example.com"
className="w-full p-3 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.preferredContactMethod || ''}
onChange={(e) => setFormData({ ...formData, preferredContactMethod: e.target.value as any })}
className="w-full p-3 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="">Не указан</option>
<option value="phone">Телефон</option>
<option value="email">Email</option>
<option value="chat">Чат</option>
<option value="in_person">Лично</option>
</select>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Заметки</label>
<textarea
value={formData.notes || ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Дополнительная информация о клиенте..."
rows={3}
className="w-full p-3 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 pt-4 border-t border-slate-200">
<button
onClick={handleSave}
className="flex-1 bg-primary-600 text-white px-6 py-3 rounded-xl font-bold text-sm uppercase flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 hover:bg-primary-700 transition-all"
>
<Save className="w-4 h-4"/> Сохранить
</button>
<button
onClick={onClose}
className="px-6 py-3 bg-slate-100 text-slate-600 rounded-xl font-bold text-sm hover:bg-slate-200 transition-all"
>
Отмена
</button>
</div>
</div>
</div>
</div>
);
};
// Sub-component: Owners Section
const OwnersSection: React.FC<{
account: PersonalAccount;
onUpdateAccount: (account: PersonalAccount) => void;
}> = ({ account, onUpdateAccount }) => {
const [editingOwnerIndex, setEditingOwnerIndex] = useState<number | null>(null);
const [editingPhoneIndex, setEditingPhoneIndex] = useState<number | null>(null);
const [editingNameIndex, setEditingNameIndex] = useState<number | null>(null);
const [phoneValue, setPhoneValue] = useState('');
const [nameValue, setNameValue] = useState('');
const [profileModalOwner, setProfileModalOwner] = useState<number | null>(null);
const sentimentConfig = {
negative: { icon: Frown, color: 'text-red-600' },
toxic: { icon: AlertTriangle, color: 'text-orange-600' },
positive: { icon: Smile, color: 'text-emerald-600' },
loyal: { icon: Heart, color: 'text-blue-600' },
neutral: { icon: User, color: 'text-slate-600' },
};
const handleSavePhone = (index: number) => {
const updated = {
...account,
owners: account.owners.map((o, idx) =>
idx === index ? { ...o, phone: phoneValue } : o
)
};
onUpdateAccount(updated);
setEditingPhoneIndex(null);
setPhoneValue('');
};
const handleSaveName = (index: number) => {
const updated = {
...account,
owners: account.owners.map((o, idx) =>
idx === index ? { ...o, fullName: nameValue } : o
)
};
onUpdateAccount(updated);
setEditingNameIndex(null);
setNameValue('');
};
return (
<>
{profileModalOwner !== null && (
<ResidentProfileModal
profile={account.owners[profileModalOwner]?.residentProfile}
personName={account.owners[profileModalOwner]?.fullName || ''}
personPhone={account.owners[profileModalOwner]?.phone}
onClose={() => setProfileModalOwner(null)}
onSave={(profile) => {
const updated = {
...account,
owners: account.owners.map((o, idx) =>
idx === profileModalOwner ? { ...o, residentProfile: profile } : o
)
};
onUpdateAccount(updated);
setProfileModalOwner(null);
}}
/>
)}
<div className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm">
<h3 className="font-bold text-slate-800 text-sm mb-4 flex items-center gap-2 pb-2 border-b border-slate-100">
<Users className="w-4 h-4 text-primary-500"/> Собственники
</h3>
<div className="space-y-3">
{account.owners.map((owner, i) => {
const sentiment = owner.residentProfile?.sentiment || 'neutral';
const SentimentIcon = sentimentConfig[sentiment as keyof typeof sentimentConfig]?.icon || User;
const sentimentColor = sentimentConfig[sentiment as keyof typeof sentimentConfig]?.color || 'text-slate-600';
const isEditingPhone = editingPhoneIndex === i;
const isEditingName = editingNameIndex === i;
return (
<div key={i} className="flex flex-col gap-2 p-3 bg-slate-50 rounded-xl border border-slate-100">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<div className="w-10 h-10 rounded-full bg-white border border-slate-200 flex items-center justify-center shrink-0">
<SentimentIcon className={`w-5 h-5 ${sentimentColor}`}/>
</div>
<div className="flex-1 min-w-0">
{isEditingName ? (
<div className="flex items-center gap-2">
<input
type="text"
value={nameValue || owner.fullName}
onChange={(e) => setNameValue(e.target.value)}
className="flex-1 p-1.5 rounded-lg border border-primary-300 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
autoFocus
/>
<button
onClick={() => handleSaveName(i)}
className="p-1.5 bg-primary-600 text-white rounded-lg"
>
<Save className="w-3.5 h-3.5"/>
</button>
<button
onClick={() => {
setEditingNameIndex(null);
setNameValue('');
}}
className="p-1.5 bg-slate-200 text-slate-600 rounded-lg"
>
<X className="w-3.5 h-3.5"/>
</button>
</div>
) : (
<div className="flex items-center gap-2">
<span
className="text-sm font-bold text-slate-700 cursor-pointer hover:text-primary-600"
onClick={() => {
setEditingNameIndex(i);
setNameValue(owner.fullName);
}}
>
{owner.fullName}
</span>
<button
onClick={() => {
setEditingNameIndex(i);
setNameValue(owner.fullName);
}}
className="p-0.5 hover:bg-slate-200 rounded text-slate-400"
>
<Edit2 className="w-3 h-3"/>
</button>
</div>
)}
{isEditingPhone ? (
<div className="flex items-center gap-2 mt-1">
<input
type="tel"
value={phoneValue || owner.phone || ''}
onChange={(e) => setPhoneValue(e.target.value)}
className="flex-1 p-1.5 rounded-lg border border-primary-300 text-xs focus:ring-2 focus:ring-primary-500 outline-none"
placeholder="+7 (999) 123-45-67"
autoFocus
/>
<button
onClick={() => handleSavePhone(i)}
className="p-1.5 bg-primary-600 text-white rounded-lg"
>
<Save className="w-3.5 h-3.5"/>
</button>
<button
onClick={() => {
setEditingPhoneIndex(null);
setPhoneValue('');
}}
className="p-1.5 bg-slate-200 text-slate-600 rounded-lg"
>
<X className="w-3.5 h-3.5"/>
</button>
</div>
) : (
<div className="flex items-center gap-2 mt-0.5 text-xs text-slate-500">
<Phone className="w-3 h-3 text-slate-400"/>
<span
className="font-medium cursor-pointer hover:text-primary-600"
onClick={() => {
setEditingPhoneIndex(i);
setPhoneValue(owner.phone || '');
}}
>
{owner.phone || 'Не указан'}
</span>
<button
onClick={() => {
setEditingPhoneIndex(i);
setPhoneValue(owner.phone || '');
}}
className="p-0.5 hover:bg-slate-200 rounded text-slate-400"
>
<Edit2 className="w-3 h-3"/>
</button>
</div>
)}
</div>
</div>
<button
onClick={() => setProfileModalOwner(i)}
className="p-1.5 hover:bg-primary-50 text-primary-600 rounded-lg transition-colors"
title="Портрет клиента"
>
<Shield className="w-4 h-4"/>
</button>
</div>
</div>
);
})}
</div>
</div>
</>
);
};
// Sub-component: Meter Edit Modal
const MeterEditModal: React.FC<{
meter: AccountMeter;
account: PersonalAccount;
onClose: () => void;
onSave: (meter: AccountMeter) => void;
}> = ({ meter, account, onClose, onSave }) => {
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({
type: meter.type,
name: meter.name || '',
make: meter.make,
number: meter.number,
manufacturer: meter.manufacturer || '',
unit: meter.unit || '',
installDate: meter.installDate || '',
lastVerification: meter.lastVerification,
nextVerification: meter.nextVerification,
currentReading: meter.currentReading || '',
notes: meter.notes || '',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.number || !formData.make) {
alert('Заполните обязательные поля: номер и модель счетчика');
return;
}
const updatedMeter: AccountMeter = {
...meter,
type: formData.type,
name: formData.name || undefined,
make: formData.make,
number: formData.number,
manufacturer: formData.manufacturer || undefined,
unit: formData.unit || undefined,
installDate: formData.installDate || undefined,
lastVerification: formData.lastVerification,
nextVerification: formData.nextVerification,
currentReading: formData.currentReading ? parseFloat(formData.currentReading.toString()) : undefined,
notes: formData.notes || undefined,
};
onSave(updatedMeter);
setIsEditing(false);
};
// Config based on type
let Icon = Gauge;
let colorClass = 'text-slate-500 bg-slate-50';
switch(formData.type) {
case 'ГВС':
Icon = Flame;
colorClass = 'text-red-500 bg-red-50';
break;
case 'ХВС':
Icon = Droplets;
colorClass = 'text-blue-500 bg-blue-50';
break;
case 'Э/Э':
Icon = Zap;
colorClass = 'text-amber-500 bg-amber-50';
break;
}
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={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 p-6 rounded-t-2xl z-10">
<div className="flex justify-between items-center">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-xl ${colorClass}`}>
<Icon className="w-5 h-5"/>
</div>
<div>
<h3 className="text-xl font-bold text-slate-800">{formData.name || meter.type} Счетчик</h3>
<p className="text-xs text-slate-500 mt-0.5">Кв. {account.apartmentNumber} {formData.number}</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setIsEditing(!isEditing)}
className="p-2 hover:bg-slate-100 rounded-full"
title={isEditing ? "Отменить редактирование" : "Редактировать"}
>
{isEditing ? <X className="w-5 h-5 text-slate-500"/> : <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>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Основная информация */}
<div>
<h4 className="font-bold text-slate-800 text-sm mb-4 flex items-center gap-2">
<Gauge className="w-4 h-4 text-primary-500"/> Основная информация
</h4>
<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>
{isEditing ? (
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as any })}
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>
<option value="ГВС">ГВС (Горячая вода)</option>
<option value="Э/Э">Э/Э (Электричество)</option>
<option value="Газ">Газ</option>
<option value="Other">Другой</option>
</select>
) : (
<p className="font-bold text-slate-800 p-2.5">{formData.type}</p>
)}
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Название
</label>
{isEditing ? (
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Название прибора"
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="font-bold text-slate-800 p-2.5">{formData.name || '-'}</p>
)}
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Номер счетчика *
</label>
{isEditing ? (
<input
type="text"
required
value={formData.number}
onChange={(e) => setFormData({ ...formData, number: e.target.value })}
placeholder="Например: 12345678"
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="font-bold text-slate-800 p-2.5">{formData.number}</p>
)}
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Модель *
</label>
{isEditing ? (
<input
type="text"
required
value={formData.make}
onChange={(e) => setFormData({ ...formData, make: e.target.value })}
placeholder="Например: Энергомера CE102"
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="font-bold text-slate-800 p-2.5">{formData.make}</p>
)}
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Производитель
</label>
{isEditing ? (
<input
type="text"
value={formData.manufacturer}
onChange={(e) => setFormData({ ...formData, manufacturer: e.target.value })}
placeholder="Производитель"
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="font-bold text-slate-800 p-2.5">{formData.manufacturer || '-'}</p>
)}
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Единица измерения
</label>
{isEditing ? (
<input
type="text"
value={formData.unit}
onChange={(e) => setFormData({ ...formData, unit: e.target.value })}
placeholder="м³, кВт⋅ч и т.д."
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="font-bold text-slate-800 p-2.5">{formData.unit || (formData.type === 'Э/Э' ? 'кВт⋅ч' : 'м³')}</p>
)}
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Дата установки
</label>
{isEditing ? (
<input
type="date"
value={formData.installDate}
onChange={(e) => setFormData({ ...formData, installDate: 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"
/>
) : (
<p className="font-bold text-slate-800 p-2.5">{formData.installDate || '-'}</p>
)}
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Текущее показание
</label>
{isEditing ? (
<input
type="number"
step="0.01"
value={formData.currentReading}
onChange={(e) => setFormData({ ...formData, currentReading: e.target.value })}
placeholder="0"
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="font-bold text-slate-800 p-2.5">{formData.currentReading ? `${formData.currentReading} ${formData.unit || (formData.type === 'Э/Э' ? 'кВт⋅ч' : 'м³')}` : '-'}</p>
)}
</div>
</div>
</div>
{/* Поверка */}
<div>
<h4 className="font-bold text-slate-800 text-sm mb-4 flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary-500"/> Поверка
</h4>
<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>
{isEditing ? (
<input
type="date"
value={formData.lastVerification}
onChange={(e) => setFormData({ ...formData, lastVerification: 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"
/>
) : (
<p className="font-bold text-slate-800 p-2.5">{formData.lastVerification || '-'}</p>
)}
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Следующая поверка *
</label>
{isEditing ? (
<input
type="date"
required
value={formData.nextVerification}
onChange={(e) => setFormData({ ...formData, nextVerification: 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"
/>
) : (
<p className={`font-bold p-2.5 ${formData.nextVerification && new Date(formData.nextVerification) < new Date() ? 'text-red-600' : 'text-slate-800'}`}>
{formData.nextVerification || '-'}
</p>
)}
</div>
</div>
</div>
{/* Примечания */}
<div>
<h4 className="font-bold text-slate-800 text-sm mb-4 flex items-center gap-2">
<FileText className="w-4 h-4 text-primary-500"/> Дополнительная информация
</h4>
{isEditing ? (
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
placeholder="Примечания..."
rows={3}
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"
/>
) : (
<p className="text-sm text-slate-800 p-2.5 bg-slate-50 rounded-xl">{formData.notes || '-'}</p>
)}
</div>
{/* Документы */}
{isEditing && (
<div>
<h4 className="font-bold text-slate-800 text-sm mb-4 flex items-center gap-2">
<File className="w-4 h-4 text-primary-500"/> Документы
</h4>
<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];
const updatedMeter = { ...meter, documents: updatedDocuments };
onSave(updatedMeter);
}}
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);
const updatedMeter = { ...meter, documents: updatedDocuments };
onSave(updatedMeter);
}}
className="text-red-500 hover:text-red-700"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Кнопки */}
{isEditing && (
<div className="flex gap-3 pt-4 border-t border-slate-200">
<button
type="submit"
className="flex-1 bg-primary-600 text-white px-6 py-3 rounded-xl font-bold text-sm uppercase flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 hover:bg-primary-700 transition-all"
>
<Save className="w-4 h-4"/> Сохранить изменения
</button>
<button
type="button"
onClick={() => {
setIsEditing(false);
// Восстанавливаем исходные данные
setFormData({
type: meter.type,
name: meter.name || '',
make: meter.make,
number: meter.number,
manufacturer: meter.manufacturer || '',
unit: meter.unit || '',
installDate: meter.installDate || '',
lastVerification: meter.lastVerification,
nextVerification: meter.nextVerification,
currentReading: meter.currentReading || '',
notes: meter.notes || '',
});
}}
className="px-6 py-3 bg-slate-100 text-slate-600 rounded-xl font-bold text-sm hover:bg-slate-200 transition-all"
>
Отмена
</button>
</div>
)}
</form>
</div>
</div>
);
};
// Sub-component: Registered Persons Section
const RegisteredPersonsSection: React.FC<{
account: PersonalAccount;
onUpdateAccount: (account: PersonalAccount) => void;
}> = ({ account, onUpdateAccount }) => {
const [isAdding, setIsAdding] = useState(false);
const [editingPerson, setEditingPerson] = useState<string | null>(null);
const [newPerson, setNewPerson] = useState({ fullName: '', phone: '', email: '' });
const handleAdd = () => {
if (!newPerson.fullName) return;
const updated = {
...account,
registered: [
...(account.registered || []),
{
id: `reg-${Date.now()}`,
fullName: newPerson.fullName,
phone: newPerson.phone,
email: newPerson.email,
}
]
};
onUpdateAccount(updated);
setNewPerson({ fullName: '', phone: '', email: '' });
setIsAdding(false);
};
const handleDelete = (personId: string) => {
if (!confirm('Удалить прописанного?')) return;
const updated = {
...account,
registered: (account.registered || []).filter(p => p.id !== personId)
};
onUpdateAccount(updated);
};
return (
<div className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm">
<div className="flex justify-between items-center mb-4 pb-2 border-b border-slate-100">
<h3 className="font-bold text-slate-800 text-sm flex items-center gap-2">
<Users className="w-4 h-4 text-primary-500"/> Прописанные ({account.registered?.length || 0})
</h3>
<button
onClick={() => setIsAdding(true)}
className="p-1.5 hover:bg-primary-50 text-primary-600 rounded-lg transition-colors"
title="Добавить прописанного"
>
<UserPlus className="w-4 h-4"/>
</button>
</div>
<div className="space-y-3">
{isAdding && (
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100 space-y-2">
<input
type="text"
placeholder="ФИО *"
value={newPerson.fullName}
onChange={(e) => setNewPerson({ ...newPerson, fullName: e.target.value })}
className="w-full p-2 rounded-lg border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
<input
type="tel"
placeholder="Телефон"
value={newPerson.phone}
onChange={(e) => setNewPerson({ ...newPerson, phone: e.target.value })}
className="w-full p-2 rounded-lg border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
<input
type="email"
placeholder="Email"
value={newPerson.email}
onChange={(e) => setNewPerson({ ...newPerson, email: e.target.value })}
className="w-full p-2 rounded-lg border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
/>
<div className="flex gap-2">
<button
onClick={handleAdd}
className="flex-1 bg-primary-600 text-white px-3 py-1.5 rounded-lg text-xs font-bold hover:bg-primary-700 transition-all"
>
Сохранить
</button>
<button
onClick={() => {
setIsAdding(false);
setNewPerson({ fullName: '', phone: '', email: '' });
}}
className="px-3 py-1.5 bg-slate-100 text-slate-600 rounded-lg text-xs font-bold hover:bg-slate-200 transition-all"
>
Отмена
</button>
</div>
</div>
)}
{(account.registered || []).map((person) => {
const isProfileOpen = editingPerson === person.id;
const toggleProfile = () => {
setEditingPerson(isProfileOpen ? null : person.id);
};
return (
<div key={person.id} className="flex flex-col gap-2 p-3 bg-slate-50 rounded-xl border border-slate-100">
<div className="flex items-center justify-between">
<div
className="flex items-center gap-3 flex-1 cursor-pointer hover:opacity-80 transition-opacity"
onClick={toggleProfile}
>
<div className="w-8 h-8 rounded-full bg-white border border-slate-200 flex items-center justify-center text-slate-400 shrink-0">
<User className="w-4 h-4"/>
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-slate-700">{person.fullName}</span>
{isProfileOpen && (
<Shield className="w-3.5 h-3.5 text-primary-500"/>
)}
</div>
{person.phone && (
<div className="flex items-center gap-1 text-xs text-slate-500 mt-0.5">
<Phone className="w-3 h-3"/>
<span>{person.phone}</span>
</div>
)}
{person.email && (
<div className="flex items-center gap-1 text-xs text-slate-500 mt-0.5">
<Mail className="w-3 h-3"/>
<span>{person.email}</span>
</div>
)}
</div>
</div>
<div className="flex gap-1">
<button
onClick={(e) => {
e.stopPropagation();
toggleProfile();
}}
className={`p-1 rounded-lg transition-colors ${
isProfileOpen
? 'bg-primary-100 text-primary-600'
: 'hover:bg-slate-200 text-slate-400'
}`}
title={isProfileOpen ? "Закрыть портрет клиента" : "Открыть портрет клиента"}
>
<Shield className="w-3.5 h-3.5"/>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(person.id);
}}
className="p-1 hover:bg-red-50 rounded-lg text-red-400"
title="Удалить"
>
<Trash2 className="w-3.5 h-3.5"/>
</button>
</div>
</div>
{isProfileOpen && (
<ResidentProfileModal
profile={person.residentProfile}
personName={person.fullName}
personPhone={person.phone}
onClose={() => setEditingPerson(null)}
onSave={(profile) => {
const updated = {
...account,
registered: (account.registered || []).map(p =>
p.id === person.id ? { ...p, residentProfile: profile } : p
)
};
onUpdateAccount(updated);
setEditingPerson(null);
}}
/>
)}
</div>
);
})}
{(!account.registered || account.registered.length === 0) && !isAdding && (
<div className="text-center py-6 text-slate-400">
<Users className="w-8 h-8 mx-auto mb-2 opacity-20"/>
<p className="text-xs font-bold">Нет прописанных</p>
</div>
)}
</div>
</div>
);
};
// Sub-component: Premise Notes Editor
const PremiseNotesEditor: React.FC<{
notes: string | undefined,
onSave: (notes: string) => void
}> = ({ notes, onSave }) => {
const [isEditing, setIsEditing] = useState(!notes);
const [text, setText] = useState(notes || '');
const handleSave = () => {
onSave(text);
setIsEditing(false);
};
return (
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
<div className="flex justify-between items-center mb-3 pb-2 border-b border-slate-100">
<h4 className="font-bold text-slate-800 text-xs flex items-center gap-2">
<FileText className="w-4 h-4 text-primary-500"/> Примечания по помещению
</h4>
{!isEditing && (
<button
onClick={() => setIsEditing(true)}
className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-primary-600 transition-colors"
>
<Edit2 className="w-3.5 h-3.5"/>
</button>
)}
</div>
{isEditing ? (
<div className="space-y-3">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Заметки о помещении: ремонты, особенности, проблемы, история изменений..."
rows={4}
className="w-full p-3 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none resize-none"
/>
<div className="flex gap-2">
<button
onClick={handleSave}
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-xl font-bold text-xs uppercase flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 hover:bg-primary-700 transition-all"
>
<Save className="w-3.5 h-3.5"/> Сохранить
</button>
<button
onClick={() => {
setIsEditing(false);
setText(notes || '');
}}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-xl font-bold text-xs hover:bg-slate-200 transition-all"
>
Отмена
</button>
</div>
</div>
) : (
<div>
{notes ? (
<p className="text-sm text-slate-700 whitespace-pre-wrap leading-relaxed">{notes}</p>
) : (
<div className="text-center py-4 text-slate-400">
<FileText className="w-6 h-6 mx-auto mb-2 opacity-20"/>
<p className="text-xs font-bold">Примечания не заполнены</p>
<button
onClick={() => setIsEditing(true)}
className="mt-2 text-xs text-primary-600 font-bold hover:underline"
>
Добавить примечание
</button>
</div>
)}
</div>
)}
</div>
);
};
// Sub-component: Account Form Modal
const AccountFormModal: React.FC<{
buildingId: string;
onClose: () => void;
onSave: (account: PersonalAccount) => void;
}> = ({ buildingId, onClose, onSave }) => {
const [formData, setFormData] = useState({
accountNumber: '',
apartmentNumber: '',
type: 'apartment' as 'apartment' | 'parking' | 'storage' | 'office',
floor: 1,
areaTotal: 0,
areaLiving: 0,
areaNonLiving: 0,
isMeterInstallationFeasible: true,
ownerName: '',
ownerPhone: '',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.apartmentNumber || !formData.ownerName) {
alert('Заполните обязательные поля: номер помещения и ФИО собственника');
return;
}
const newAccount: PersonalAccount = {
id: `${buildingId}-acc-${Date.now()}`,
accountNumber: formData.accountNumber.trim(), // Лицевой счет, введенный пользователем
apartmentNumber: formData.apartmentNumber,
type: formData.type,
floor: formData.floor,
owners: [{
fullName: formData.ownerName,
phone: formData.ownerPhone,
}],
registered: [],
areaTotal: formData.areaTotal,
areaLiving: formData.areaLiving,
areaNonLiving: formData.areaNonLiving,
meters: [],
isMeterInstallationFeasible: formData.isMeterInstallationFeasible,
};
onSave(newAccount);
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={onClose}>
<div className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl animate-slide-up" onClick={e => e.stopPropagation()}>
<div className="sticky top-0 bg-white border-b border-slate-200 p-6 rounded-t-2xl z-10">
<div className="flex justify-between items-center">
<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>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Основная информация */}
<div>
<h4 className="font-bold text-slate-800 text-sm mb-4 flex items-center gap-2">
<Building2 className="w-4 h-4 text-primary-500"/> Основная информация
</h4>
<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="text"
value={formData.accountNumber}
onChange={(e) => setFormData({ ...formData, accountNumber: e.target.value })}
placeholder="Например: 1234567890"
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="text"
required
value={formData.apartmentNumber}
onChange={(e) => setFormData({ ...formData, apartmentNumber: e.target.value })}
placeholder="Например: 15, 25А"
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 any })}
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="apartment">Квартира</option>
<option value="parking">Парковка</option>
<option value="storage">Кладовка</option>
<option value="office">Офис</option>
</select>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Этаж *
</label>
<input
type="number"
required
min="1"
value={formData.floor}
onChange={(e) => setFormData({ ...formData, floor: parseInt(e.target.value) || 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>
</div>
{/* Площадь */}
<div>
<h4 className="font-bold text-slate-800 text-sm mb-4">Площадь (м²)</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">
Общая *
</label>
<input
type="number"
required
min="0"
step="0.01"
value={formData.areaTotal}
onChange={(e) => setFormData({ ...formData, areaTotal: parseFloat(e.target.value) || 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="number"
required
min="0"
step="0.01"
value={formData.areaLiving}
onChange={(e) => setFormData({ ...formData, areaLiving: parseFloat(e.target.value) || 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="number"
min="0"
step="0.01"
value={formData.areaNonLiving}
onChange={(e) => setFormData({ ...formData, areaNonLiving: parseFloat(e.target.value) || 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>
</div>
{/* Собственник */}
<div>
<h4 className="font-bold text-slate-800 text-sm mb-4 flex items-center gap-2">
<User className="w-4 h-4 text-primary-500"/> Собственник
</h4>
<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="text"
required
value={formData.ownerName}
onChange={(e) => setFormData({ ...formData, ownerName: e.target.value })}
placeholder="Иванов Иван Иванович"
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="tel"
value={formData.ownerPhone}
onChange={(e) => setFormData({ ...formData, ownerPhone: e.target.value })}
placeholder="+7 (999) 123-45-67"
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>
</div>
{/* Техническая возможность ПУ */}
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.isMeterInstallationFeasible}
onChange={(e) => setFormData({ ...formData, isMeterInstallationFeasible: e.target.checked })}
className="w-5 h-5 rounded border-slate-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm font-medium text-slate-700">
Техническая возможность установки приборов учета
</span>
</label>
</div>
{/* Кнопки */}
<div className="flex gap-3 pt-4 border-t border-slate-200">
<button
type="submit"
className="flex-1 bg-primary-600 text-white px-6 py-3 rounded-xl font-bold text-sm uppercase flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 hover:bg-primary-700 transition-all"
>
<Save className="w-4 h-4"/> Создать счет
</button>
<button
type="button"
onClick={onClose}
className="px-6 py-3 bg-slate-100 text-slate-600 rounded-xl font-bold text-sm hover:bg-slate-200 transition-all"
>
Отмена
</button>
</div>
</form>
</div>
</div>
);
};
// Sub-component: Account Detail
const AccountDetailView: React.FC<{
account: PersonalAccount,
debtorInfo?: Debtor,
onBack: () => void,
onUpdateAccount: (updatedAccount: PersonalAccount) => void,
onDelete: () => void
}> = ({ account, debtorInfo, onBack, onUpdateAccount, onDelete }) => {
const [selectedMeter, setSelectedMeter] = useState<AccountMeter | null>(null);
const [editingMeter, setEditingMeter] = useState<AccountMeter | null>(null);
const [showAddMeterModal, setShowAddMeterModal] = useState(false);
const handleMeterUpdate = (updatedMeter: AccountMeter) => {
const updatedMeters = account.meters.map(m =>
m.id === updatedMeter.id ? updatedMeter : m
);
onUpdateAccount({ ...account, meters: updatedMeters });
setEditingMeter(null);
};
return (
<div className="animate-fade-in space-y-6">
{selectedMeter && (
<MeterHistoryModal
meter={selectedMeter}
onClose={() => setSelectedMeter(null)}
onAddReading={(reading) => {
const updatedMeters = account.meters.map(m =>
m.id === selectedMeter.id
? { ...m, readings: [reading, ...m.readings] }
: m
);
onUpdateAccount({ ...account, meters: updatedMeters });
setSelectedMeter({ ...selectedMeter, readings: [reading, ...selectedMeter.readings] });
}}
/>
)}
{editingMeter && (
<MeterEditModal
meter={editingMeter}
account={account}
onClose={() => setEditingMeter(null)}
onSave={handleMeterUpdate}
/>
)}
<AddAccountMeterModal
isOpen={showAddMeterModal}
account={account}
onClose={() => setShowAddMeterModal(false)}
onAdd={(newMeter) => {
const updatedMeters = [...account.meters, newMeter];
onUpdateAccount({ ...account, meters: updatedMeters });
}}
/>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={onBack} className="p-2 -ml-2 hover:bg-slate-200 rounded-full text-slate-500 transition-colors">
<ArrowLeft className="w-5 h-5"/>
</button>
<div>
<div className="flex items-center gap-3">
<h2 className="text-xl font-bold text-slate-800">
{account.type === 'apartment' ? 'Квартира' :
account.type === 'parking' ? 'Парковка' :
account.type === 'storage' ? 'Кладовка' : 'Офис'} {account.apartmentNumber}
</h2>
{debtorInfo && (
<span className="text-[10px] font-bold uppercase bg-red-100 text-red-600 px-2 py-0.5 rounded border border-red-200">
Долг: {debtorInfo.amount.toLocaleString()}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-slate-500 font-medium">Л/С:</span>
<AccountNumberInput
value={account.accountNumber || ''}
onChange={(value) => {
setTimeout(() => {
onUpdateAccount({ ...account, accountNumber: value });
}, 0);
}}
/>
</div>
</div>
</div>
<button
onClick={() => {
if (confirm('Вы уверены, что хотите удалить этот лицевой счет?')) {
onDelete();
}
}}
className="p-2.5 hover:bg-red-50 text-red-500 rounded-xl transition-all border border-red-200"
title="Удалить лицевой счет"
>
<Trash2 className="w-5 h-5"/>
</button>
</div>
{/* Main Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column: Info */}
<div className="lg:col-span-2 space-y-6">
{/* General Info Card */}
<div className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm">
<h3 className="font-bold text-slate-800 text-sm mb-4 flex items-center gap-2 pb-2 border-b border-slate-100">
<Building2 className="w-4 h-4 text-primary-500"/> Характеристики помещения
</h3>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100">
<span className="text-[10px] text-slate-400 font-bold uppercase block mb-1">Тип</span>
<select
value={account.type}
onChange={(e) => {
onUpdateAccount({ ...account, type: e.target.value as any });
}}
className="text-base font-bold text-slate-800 bg-transparent border-b-2 border-slate-300 focus:border-primary-500 outline-none w-full"
>
<option value="apartment">Квартира</option>
<option value="parking">Парковка</option>
<option value="storage">Кладовка</option>
<option value="office">Офис</option>
</select>
</div>
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100">
<span className="text-[10px] text-slate-400 font-bold uppercase block mb-1">Номер</span>
<EditableTextInput
value={account.apartmentNumber || ''}
onChange={(value) => {
onUpdateAccount({ ...account, apartmentNumber: value });
}}
className="text-base font-bold text-slate-800 w-full"
placeholder="№"
/>
</div>
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100">
<span className="text-[10px] text-slate-400 font-bold uppercase block mb-1">Этаж</span>
<EditableNumberInput
value={account.floor || 0}
onChange={(value) => {
onUpdateAccount({ ...account, floor: value });
}}
className="text-base font-bold text-slate-800 w-full"
placeholder="0"
/>
</div>
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100">
<span className="text-[10px] text-slate-400 font-bold uppercase block mb-1">Общая</span>
<div className="flex items-center gap-1">
<EditableNumberInput
value={account.areaTotal || 0}
onChange={(value) => {
onUpdateAccount({ ...account, areaTotal: value });
}}
className="text-base font-bold text-slate-800 flex-1"
placeholder="0"
step="0.01"
/>
<span className="text-xs font-normal text-slate-500">м²</span>
</div>
</div>
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100">
<span className="text-[10px] text-slate-400 font-bold uppercase block mb-1">Жилая</span>
<div className="flex items-center gap-1">
<EditableNumberInput
value={account.areaLiving || 0}
onChange={(value) => {
onUpdateAccount({ ...account, areaLiving: value });
}}
className="text-base font-bold text-slate-800 flex-1"
placeholder="0"
step="0.01"
/>
<span className="text-xs font-normal text-slate-500">м²</span>
</div>
</div>
</div>
{account.type !== 'apartment' && (
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100 mb-4">
<span className="text-[10px] text-slate-400 font-bold uppercase block mb-1">Нежилая</span>
<div className="flex items-center gap-1">
<EditableNumberInput
value={account.areaNonLiving || 0}
onChange={(value) => {
onUpdateAccount({ ...account, areaNonLiving: value });
}}
className="text-base font-bold text-slate-800 flex-1"
placeholder="0"
step="0.01"
/>
<span className="text-xs font-normal text-slate-500">м²</span>
</div>
</div>
)}
{/* Примечания по помещению */}
<PremiseNotesEditor
notes={account.premiseNotes}
onSave={(notes) => {
const updated = { ...account, premiseNotes: notes };
onUpdateAccount(updated);
}}
/>
</div>
{/* Meters Section */}
<div>
<div className="flex justify-between items-center mb-3">
<h3 className="font-bold text-slate-700 text-sm uppercase px-1 flex items-center gap-2">
<Gauge className="w-4 h-4"/> Индивидуальные приборы учета
</h3>
<button
onClick={() => setShowAddMeterModal(true)}
className="text-xs font-bold text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<Plus className="w-3.5 h-3.5"/> Добавить
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{account.meters.map((meter) => {
const Icon = meter.type === 'ХВС' ? Droplets : meter.type === 'ГВС' ? Flame : meter.type === 'Э/Э' ? Zap : Gauge;
const colorClass = meter.type === 'ХВС' ? 'text-blue-500 bg-blue-50' : meter.type === 'ГВС' ? 'text-red-500 bg-red-50' : meter.type === 'Э/Э' ? 'text-amber-500 bg-amber-50' : 'text-slate-500 bg-slate-50';
// Check verification date validity
const nextVer = new Date(meter.nextVerification);
const isExpired = nextVer < new Date();
const isSoon = nextVer < new Date(new Date().setMonth(new Date().getMonth() + 2));
return (
<div
key={meter.id}
className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm hover:shadow-md transition-all group relative overflow-hidden"
>
<div className="flex justify-between items-start mb-3">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-xl ${colorClass}`}>
<Icon className="w-5 h-5"/>
</div>
<div>
<p className="font-bold text-slate-800">{meter.type}</p>
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-wide"> {meter.number}</p>
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => setSelectedMeter(meter)}
className="p-1 rounded-full hover:bg-slate-100 text-slate-300 group-hover:text-primary-500 transition-colors"
title="История"
>
<History className="w-4 h-4"/>
</button>
<button
onClick={() => setEditingMeter(meter)}
className="p-1 rounded-full hover:bg-slate-100 text-slate-300 group-hover:text-primary-500 transition-colors"
title="Редактировать"
>
<Edit2 className="w-4 h-4"/>
</button>
</div>
</div>
<div className="space-y-2 text-xs border-t border-slate-100 pt-3">
<div className="flex justify-between">
<span className="text-slate-500">Модель:</span>
<span className="font-medium text-slate-700">{meter.make}</span>
</div>
{meter.manufacturer && (
<div className="flex justify-between">
<span className="text-slate-500">Производитель:</span>
<span className="font-medium text-slate-700">{meter.manufacturer}</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-slate-500">Поверка до:</span>
<span className={`font-bold px-1.5 py-0.5 rounded ${isExpired ? 'bg-red-100 text-red-600' : isSoon ? 'bg-amber-100 text-amber-600' : 'bg-emerald-50 text-emerald-600'}`}>
{meter.nextVerification}
</span>
</div>
{(meter.currentReading !== undefined || meter.readings.length > 0) && (
<div className="flex justify-between items-center pt-2 border-t border-slate-100">
<span className="text-slate-500">Текущее показание:</span>
<span className="font-bold text-slate-800">
{(meter.currentReading !== undefined ? meter.currentReading : meter.readings[0]?.value || 0).toLocaleString('ru-RU', { maximumFractionDigits: 2 })} {meter.unit || (meter.type === 'Э/Э' ? 'кВт⋅ч' : 'м³')}
</span>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Right Column: People & Tech */}
<div className="space-y-6">
<OwnersSection
account={account}
onUpdateAccount={onUpdateAccount}
/>
<RegisteredPersonsSection
account={account}
onUpdateAccount={onUpdateAccount}
/>
<div className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm">
<h3 className="font-bold text-slate-800 text-sm mb-4 flex items-center gap-2 pb-2 border-b border-slate-100">
<FileCheck className="w-4 h-4 text-primary-500"/> Тех. возможность ПУ
</h3>
<div className={`p-4 rounded-xl flex items-start gap-3 mb-3 ${account.isMeterInstallationFeasible ? 'bg-emerald-50 border border-emerald-100' : 'bg-red-50 border border-red-100'}`}>
{account.isMeterInstallationFeasible ? <CheckCircle2 className="w-5 h-5 text-emerald-600 mt-0.5"/> : <X className="w-5 h-5 text-red-600 mt-0.5"/>}
<div>
<p className={`font-bold text-sm ${account.isMeterInstallationFeasible ? 'text-emerald-800' : 'text-red-800'}`}>
{account.isMeterInstallationFeasible ? 'Установка возможна' : 'Технически невозможна'}
</p>
{!account.isMeterInstallationFeasible && (
<p className="text-xs text-red-600 mt-1 opacity-80 leading-snug">
Требуется установка по нормативу
</p>
)}
</div>
</div>
{!account.isMeterInstallationFeasible && account.surveyActDate && (
<div className="text-xs text-slate-500 bg-slate-50 p-2 rounded border border-slate-100">
<b>Акт обследования:</b> {account.surveyActNumber || 'Б/Н'} от {account.surveyActDate}
</div>
)}
</div>
</div>
</div>
</div>
);
};
// Helper: миграция старых данных
const migrateAccount = (acc: any): PersonalAccount => {
// Если есть старое поле registeredCount, но нет registered - создаем пустой массив
if (!acc.registered && acc.registeredCount !== undefined) {
return {
...acc,
registered: [],
owners: (acc.owners || []).map((o: any) => ({
fullName: o.fullName || '',
phone: o.phone || '',
residentProfile: o.residentProfile
}))
};
}
// Если нет registered вообще - создаем пустой массив
if (!acc.registered) {
return {
...acc,
registered: [],
owners: (acc.owners || []).map((o: any) => ({
fullName: o.fullName || '',
phone: o.phone || '',
residentProfile: o.residentProfile
}))
};
}
return acc as PersonalAccount;
};
export const AccountsView: React.FC<{
building: Building,
setBuilding?: React.Dispatch<React.SetStateAction<Building>>
}> = ({ building, setBuilding }) => {
// Мигрируем старые данные при загрузке
const accounts = useMemo(() => {
return (building.accounts || []).map(migrateAccount);
}, [building.accounts]);
const debtors = building.financials.topDebtors || [];
const [selectedAccount, setSelectedAccount] = useState<PersonalAccount | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const handleUpdateAccount = async (updatedAccount: PersonalAccount) => {
// Мигрируем обновленный счет
const migratedAccount = migrateAccount(updatedAccount);
const updatedAccounts = accounts.map(acc =>
acc.id === migratedAccount.id ? migratedAccount : acc
);
if (setBuilding) {
setBuilding(prev => ({
...prev,
accounts: updatedAccounts,
isDirty: true
}));
// Пытаемся сохранить в БД
try {
await backendApi.updateAccount(building.id, updatedAccount);
window.dispatchEvent(new CustomEvent(REFRESH_EVENTS.buildingAccounts));
window.dispatchEvent(new CustomEvent(REFRESH_EVENTS.dashboard));
} catch (error) {
console.error('[AccountsView] Failed to sync account to backend:', error);
}
}
};
const handleCreateAccount = async (newAccount: PersonalAccount) => {
if (setBuilding) {
// Мигрируем новый счет
const migratedAccount = migrateAccount(newAccount);
const updatedAccounts = [...accounts, migratedAccount];
setBuilding(prev => ({
...prev,
accounts: updatedAccounts,
isDirty: true
}));
try {
await backendApi.createAccount(building.id, newAccount);
setIsCreateModalOpen(false);
window.dispatchEvent(new CustomEvent(REFRESH_EVENTS.buildingAccounts));
window.dispatchEvent(new CustomEvent(REFRESH_EVENTS.dashboard));
} catch (error) {
console.error('[AccountsView] Failed to create account in backend:', error);
}
}
};
const handleDeleteAccount = async (accountId: string) => {
if (setBuilding) {
const updatedAccounts = accounts.filter(acc => acc.id !== accountId);
setBuilding(prev => ({
...prev,
accounts: updatedAccounts,
isDirty: true
}));
try {
await backendApi.deleteAccount(building.id, accountId);
if (selectedAccount?.id === accountId) {
setSelectedAccount(null);
}
window.dispatchEvent(new CustomEvent(REFRESH_EVENTS.buildingAccounts));
window.dispatchEvent(new CustomEvent(REFRESH_EVENTS.dashboard));
} catch (error) {
console.error('[AccountsView] Failed to delete account in backend:', error);
}
}
};
const filteredAccounts = useMemo(() => {
const lowerSearch = searchTerm.toLowerCase();
return accounts.filter(acc =>
acc.apartmentNumber.includes(lowerSearch) ||
acc.accountNumber.includes(lowerSearch) ||
acc.owners.some(o => o.fullName.toLowerCase().includes(lowerSearch))
);
}, [accounts, searchTerm]);
const getDebtorInfo = (aptNum: string) => {
return debtors.find(d => d.apartment === aptNum);
};
if (selectedAccount) {
return (
<>
<AccountDetailView
account={selectedAccount}
debtorInfo={getDebtorInfo(selectedAccount.apartmentNumber)}
onBack={() => setSelectedAccount(null)}
onUpdateAccount={(updated) => {
handleUpdateAccount(updated);
setSelectedAccount(updated);
}}
onDelete={() => {
handleDeleteAccount(selectedAccount.id);
setSelectedAccount(null);
}}
/>
</>
);
}
return (
<div className="space-y-6 animate-fade-in">
{isCreateModalOpen && (
<AccountFormModal
buildingId={building.id}
onClose={() => setIsCreateModalOpen(false)}
onSave={handleCreateAccount}
/>
)}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<h3 className="font-bold text-slate-800 text-lg">Реестр лицевых счетов</h3>
<p className="text-xs text-slate-500">Всего помещений: {accounts.length}</p>
</div>
<div className="flex gap-3">
{/* Search */}
<div className="relative w-full md:w-72">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Поиск по квартире, Л/С..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 rounded-xl border border-slate-200 shadow-sm outline-none focus:ring-2 focus:ring-primary-500 text-sm bg-white"
/>
</div>
{/* Add Button */}
<button
onClick={() => setIsCreateModalOpen(true)}
className="bg-primary-600 text-white px-4 py-2.5 rounded-xl shadow-lg flex items-center gap-2 text-xs font-bold uppercase hover:bg-primary-700 transition-all active:scale-95"
>
<Plus className="w-4 h-4"/> Добавить
</button>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="hidden md:block">
<table className="w-full text-sm text-left">
<thead className="bg-slate-50 text-slate-500 font-bold border-b border-slate-200 text-xs uppercase tracking-wider">
<tr>
<th className="p-4">Л/С</th>
<th className="p-4 text-center">Квартира</th>
<th className="p-4">Собственник</th>
<th className="p-4 text-center">Площадь</th>
<th className="p-4 text-center">Прописано</th>
<th className="p-4 text-right">Баланс</th>
<th className="p-4"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredAccounts.map(acc => {
const debtor = getDebtorInfo(acc.apartmentNumber);
return (
<tr key={acc.id} onClick={() => setSelectedAccount(acc)} className="hover:bg-slate-50 cursor-pointer group transition-colors">
<td className="p-4 font-bold text-slate-700">{acc.accountNumber}</td>
<td className="p-4 text-center">
<div className="flex flex-col items-center gap-1">
<span className="font-bold bg-slate-100 text-slate-700 px-2.5 py-1 rounded-lg group-hover:bg-white transition-colors border border-transparent group-hover:border-slate-200">
{acc.apartmentNumber}
</span>
<span className="text-[9px] text-slate-400 font-bold uppercase">
{acc.type === 'apartment' ? 'Кв.' :
acc.type === 'parking' ? 'Парк.' :
acc.type === 'storage' ? 'Клад.' : 'Офис'}
</span>
</div>
</td>
<td className="p-4 text-slate-600">
<div className="font-medium truncate max-w-[180px] text-slate-800">{acc.owners[0]?.fullName}</div>
{acc.owners.length > 1 && <div className="text-[10px] text-slate-400 font-medium">+{acc.owners.length - 1} совл.</div>}
</td>
<td className="p-4 text-center text-slate-600 font-medium">{acc.areaTotal} <span className="text-slate-400 text-xs font-normal">м²</span></td>
<td className="p-4 text-center">
<span className="bg-slate-50 text-slate-600 px-2 py-1 rounded text-xs font-bold border border-slate-100">{acc.registered?.length || 0}</span>
</td>
<td className="p-4 text-right">
{debtor ? (
<div className="flex flex-col items-end">
<span className="text-red-600 font-bold">-{debtor.amount.toLocaleString()} </span>
<span className="text-[9px] bg-red-100 text-red-600 px-1.5 rounded uppercase font-bold">Долг</span>
</div>
) : (
<span className="text-emerald-600 font-bold text-xs">0 </span>
)}
</td>
<td className="p-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={(e) => {
e.stopPropagation();
if (confirm('Вы уверены, что хотите удалить этот лицевой счет?')) {
handleDeleteAccount(acc.id);
}
}}
className="p-1.5 hover:bg-red-50 text-red-400 hover:text-red-600 rounded-lg transition-all"
title="Удалить"
>
<Trash2 className="w-4 h-4"/>
</button>
<ChevronRight className="w-5 h-5 text-slate-300 group-hover:text-primary-500 transition-colors"/>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Mobile View */}
<div className="md:hidden divide-y divide-slate-100">
{filteredAccounts.map(acc => {
const debtor = getDebtorInfo(acc.apartmentNumber);
return (
<div key={acc.id} onClick={() => setSelectedAccount(acc)} className="p-4 active:bg-slate-50 flex justify-between items-center cursor-pointer">
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-bold text-lg text-slate-800">Кв. {acc.apartmentNumber}</span>
{debtor && <AlertCircle className="w-4 h-4 text-red-500 fill-red-50"/>}
</div>
<p className="text-sm text-slate-600 mb-1">{acc.owners.map(o => o.fullName).join(', ')}</p>
<div className="flex items-center gap-3">
<span className="text-xs text-slate-400 bg-slate-50 px-1.5 py-0.5 rounded border border-slate-100">{acc.accountNumber}</span>
<span className="text-xs text-slate-400">{acc.areaTotal} м²</span>
</div>
</div>
<div className="text-right">
{debtor ? (
<div className="mb-2">
<span className="block text-sm font-bold text-red-600">-{debtor.amount / 1000}к</span>
</div>
) : null}
<ChevronRight className="w-5 h-5 text-slate-300 ml-auto"/>
</div>
</div>
);
})}
</div>
{filteredAccounts.length === 0 && <p className="p-12 text-center text-slate-400 text-sm">Лицевые счета не найдены</p>}
</div>
</div>
);
};