2653 lines
143 KiB
TypeScript
Executable File
2653 lines
143 KiB
TypeScript
Executable File
|
||
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>
|
||
);
|
||
};
|