Files
mkd/components/building/AccountsView.tsx
2026-02-04 00:17:04 +05:00

2653 lines
143 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, 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>
);
};