Files
mkd/components/building/MeterCheckView.tsx

1172 lines
75 KiB
TypeScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useMemo, useEffect } from 'react';
import { Building, MeterCheckRound, PersonalAccount, AccountMeter, MeterReading } from '../../types';
import {
ClipboardCheck,
Plus,
ChevronRight,
Search,
DoorClosed,
CheckCircle2,
RotateCcw,
X,
ArrowLeft,
Gauge,
AlertTriangle,
Activity,
User,
ShieldAlert,
TrendingUp,
Zap,
Droplets,
Flame
} from 'lucide-react';
import { storageService } from '../../services/storageService';
import { backendApi } from '../../services/apiClient';
// ====================================================================================
// UTILS
// ====================================================================================
const getMeterIcon = (type: string) => {
const t = type.toLowerCase();
if (t.includes('гвс') || t.includes('хвс') || t.includes('вод')) return Droplets;
if (t.includes('эл') || t.includes('э/э')) return Zap;
if (t.includes('газ')) return Flame;
return Gauge;
};
/** При завершении обхода записывает показания в ОДПУ (passport.meters) и квартирные ПУ (accounts[].meters). */
function applyRoundReadingsToBuilding(building: Building, round: MeterCheckRound): Building {
const today = new Date().toISOString().slice(0, 10);
const b: Building = { ...building };
b.passport = { ...b.passport, meters: [...(b.passport?.meters || [])] };
b.accounts = [...(b.accounts || [])];
const addReading = (prevVal: number, curVal: number): MeterReading => ({
date: today,
value: curVal,
consumption: Math.max(0, curVal - prevVal),
source: 'manual'
});
round.odpuMeters?.forEach((om) => {
if (om.status !== 'verified' || om.currentValue == null) return;
const idx = om.meterIndex;
const m = b.passport!.meters![idx];
if (!m) return;
const reading = addReading(om.previousValue ?? 0, om.currentValue);
const readings = [...(m.readings || []), reading].sort((a, b) => b.date.localeCompare(a.date));
b.passport!.meters![idx] = { ...m, readings, currentReading: om.currentValue };
});
const accMap = new Map<string, PersonalAccount>();
b.accounts.forEach((a) => accMap.set(a.apartmentNumber, a));
round.apartments?.forEach((apt) => {
if (apt.status !== 'verified') return;
const acc = accMap.get(apt.apartmentNumber);
if (!acc || !acc.meters?.length) return;
let newMeters = [...acc.meters];
apt.readings.forEach((r) => {
if (r.currentValue == null) return;
const mi = newMeters.findIndex((m) => m.id === r.meterId);
if (mi < 0) return;
const meter = newMeters[mi];
const reading = addReading(r.previousValue ?? 0, r.currentValue);
const meterReadings = [...(meter.readings || []), reading].sort((a, b) => b.date.localeCompare(a.date));
newMeters[mi] = { ...meter, readings: meterReadings, currentReading: r.currentValue };
});
accMap.set(apt.apartmentNumber, { ...acc, meters: newMeters });
});
b.accounts = b.accounts.map((a) => accMap.get(a.apartmentNumber) ?? a);
const rounds = [...(b.meterCheckRounds || [])];
const ri = rounds.findIndex((r) => r.id === round.id);
if (ri >= 0) rounds[ri] = round;
else rounds.unshift(round);
b.meterCheckRounds = rounds;
return b;
}
/** Сохраняет здание в localStorage и в БД (backend). */
async function persistBuilding(updated: Building): Promise<void> {
storageService.saveBuildingData(updated);
try {
await backendApi.updateBuilding(updated);
} catch (e) {
console.warn('[MeterCheckView] Не удалось синхронизировать с БД:', e);
}
}
// ====================================================================================
// ANALYTICS SUB-COMPONENT
// ====================================================================================
const RoundAnalytics: React.FC<{ round: MeterCheckRound }> = ({ round }) => {
const issues = useMemo(() => {
const results = {
suspicious: [] as any[],
noReadings: [] as any[],
bigGap: [] as any[]
};
// Анализ квартир
round.apartments?.forEach(apt => {
if (apt.status === 'verified') {
apt.readings.forEach(r => {
const diff = (r.currentValue || 0) - r.previousValue;
// Example logic: if user reading was suspiciously low or gap is huge
if (diff > 50) results.bigGap.push({ apt: apt.apartmentNumber, type: r.meterType, val: diff });
if (r.currentValue === r.previousValue && r.previousValue !== 0) {
results.suspicious.push({ apt: apt.apartmentNumber, type: r.meterType });
}
});
}
if (apt.status === 'no_access') {
results.noReadings.push(apt.apartmentNumber);
}
});
// Анализ ОДПУ
round.odpuMeters?.forEach(meter => {
if (meter.status === 'verified') {
const diff = (meter.currentValue || 0) - meter.previousValue;
if (diff > 50) results.bigGap.push({ apt: meter.meterName, type: meter.meterType, val: diff });
if (meter.currentValue === meter.previousValue && meter.previousValue !== 0) {
results.suspicious.push({ apt: meter.meterName, type: meter.meterType });
}
}
if (meter.status === 'no_access') {
results.noReadings.push(meter.meterName);
}
});
return results;
}, [round]);
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-red-50 p-4 rounded-2xl border border-red-100">
<div className="flex items-center gap-2 text-red-600 mb-2">
<ShieldAlert className="w-5 h-5"/>
<h4 className="text-xs font-black uppercase tracking-wider">Аномальный расход</h4>
</div>
<p className="text-2xl font-black text-red-700">{issues.bigGap.length}</p>
<p className="text-[10px] text-red-500 font-bold mt-1">Разница {'>'} 50 ед. от прошлого периода</p>
</div>
<div className="bg-amber-50 p-4 rounded-2xl border border-amber-100">
<div className="flex items-center gap-2 text-amber-600 mb-2">
<Activity className="w-5 h-5"/>
<h4 className="text-xs font-black uppercase tracking-wider">Подозрение на магнит</h4>
</div>
<p className="text-2xl font-black text-amber-700">{issues.suspicious.length}</p>
<p className="text-[10px] text-amber-500 font-bold mt-1">Нулевое потребление при наличии жильцов</p>
</div>
<div className="bg-slate-50 p-4 rounded-2xl border border-slate-100">
<div className="flex items-center gap-2 text-slate-600 mb-2">
<DoorClosed className="w-5 h-5"/>
<h4 className="text-xs font-black uppercase tracking-wider">Недопуск</h4>
</div>
<p className="text-2xl font-black text-slate-700">{issues.noReadings.length}</p>
<p className="text-[10px] text-slate-500 font-bold mt-1">Требуется повторный вызов или уведомление</p>
</div>
</div>
);
};
// ====================================================================================
// ROUND DETAILS
// ====================================================================================
const RoundDetails: React.FC<{
round: MeterCheckRound,
onSave: (round: MeterCheckRound) => void,
onBack: () => void,
building: Building
}> = ({ round, onSave, onBack, building }) => {
const [data, setData] = useState<MeterCheckRound>(round);
const [selectedApt, setSelectedApt] = useState<string | null>(null);
const [selectedOdpuMeter, setSelectedOdpuMeter] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [viewMode, setViewMode] = useState<'list' | 'stats'>('list');
/** Локальные значения показаний в модалке квартиры (meterId -> currentValue) для корректного сохранения. */
const [aptModalValues, setAptModalValues] = useState<Record<string, number>>({});
const currentApt = data.apartments?.find(a => a.apartmentNumber === selectedApt);
const currentOdpuMeter = data.odpuMeters?.find(m => m.meterId === selectedOdpuMeter);
useEffect(() => {
if (!selectedApt || !currentApt) {
setAptModalValues({});
return;
}
const v: Record<string, number> = {};
currentApt.readings.forEach(r => {
if (r.currentValue != null) v[r.meterId] = r.currentValue;
});
setAptModalValues(v);
}, [selectedApt, currentApt?.apartmentNumber]);
const handleUpdateApt = (aptNumber: string, status: 'verified' | 'no_access', readings?: any) => {
setData(prev => ({
...prev,
apartments: prev.apartments?.map(a =>
a.apartmentNumber === aptNumber
? { ...a, status, checkedAt: new Date().toISOString(), readings: readings || a.readings }
: a
) || []
}));
setSelectedApt(null);
};
const handleUpdateOdpuMeter = (meterId: string, status: 'verified' | 'no_access', currentValue?: number) => {
setData(prev => ({
...prev,
odpuMeters: prev.odpuMeters?.map(m =>
m.meterId === meterId
? { ...m, status, checkedAt: new Date().toISOString(), currentValue: currentValue || m.currentValue }
: m
) || []
}));
setSelectedOdpuMeter(null);
};
const stats = {
total: (data.apartments?.length || 0) + (data.odpuMeters?.length || 0),
verified: (data.apartments?.filter(a => a.status === 'verified').length || 0) + (data.odpuMeters?.filter(m => m.status === 'verified').length || 0),
noAccess: (data.apartments?.filter(a => a.status === 'no_access').length || 0) + (data.odpuMeters?.filter(m => m.status === 'no_access').length || 0),
apartments: {
total: data.apartments?.length || 0,
verified: data.apartments?.filter(a => a.status === 'verified').length || 0,
noAccess: data.apartments?.filter(a => a.status === 'no_access').length || 0,
},
odpu: {
total: data.odpuMeters?.length || 0,
verified: data.odpuMeters?.filter(m => m.status === 'verified').length || 0,
noAccess: data.odpuMeters?.filter(m => m.status === 'no_access').length || 0,
}
};
const filteredApts = data.apartments?.filter(a =>
a.apartmentNumber.includes(searchTerm)
) || [];
const filteredOdpuMeters = data.odpuMeters?.filter(m =>
m.meterName.toLowerCase().includes(searchTerm.toLowerCase()) ||
m.meterType.toLowerCase().includes(searchTerm.toLowerCase())
) || [];
return (
<div className="animate-fade-in space-y-6">
<div className="flex items-center justify-between sticky top-[70px] bg-slate-50/95 py-2 z-30 -mx-4 px-4 md:-mx-8 md:px-8 border-b border-slate-200 backdrop-blur-md">
<div className="flex items-center gap-3">
<button onClick={onBack} className="p-2 hover:bg-slate-200 rounded-full text-slate-500">
<ArrowLeft className="w-5 h-5"/>
</button>
<div>
<h2 className="font-bold text-slate-800 leading-none">Обход {data.date}</h2>
<div className="text-xs text-slate-500 mt-1">
<p>
{data.roundType === 'odpu' ? 'Только ОДПУ' :
data.roundType === 'apartments' ? 'По квартирно' :
'Все счетчики'}
</p>
{(data.selectedEntrances && data.selectedEntrances.length > 0) && (
<p className="mt-1">Подъезды: {data.selectedEntrances.sort((a, b) => a - b).join(', ')}</p>
)}
{(data.selectedFloors && data.selectedFloors.length > 0) && (
<p className="mt-1">Этажи: {data.selectedFloors.sort((a, b) => a - b).join(', ')}</p>
)}
</div>
<div className="flex gap-2 mt-1">
<button onClick={() => setViewMode('list')} className={`text-[10px] font-black uppercase ${viewMode === 'list' ? 'text-primary-600' : 'text-slate-400'}`}>Список</button>
<button onClick={() => setViewMode('stats')} className={`text-[10px] font-black uppercase ${viewMode === 'stats' ? 'text-primary-600' : 'text-slate-400'}`}>Аналитика</button>
</div>
</div>
</div>
<button
onClick={() => onSave({...data, status: 'completed'})}
className="bg-slate-900 text-white px-5 py-2 rounded-xl text-xs font-bold shadow-xl active:scale-95 transition-all"
>
Завершить
</button>
</div>
{viewMode === 'stats' ? (
<div className="animate-fade-in">
<RoundAnalytics round={data} />
<div className="bg-white p-6 rounded-3xl border border-slate-200 shadow-sm">
<h3 className="font-bold text-slate-800 mb-4">Журнал выявленных расхождений</h3>
<div className="space-y-3">
{/* Расхождения по квартирам */}
{data.apartments?.filter(a => a.status === 'verified').map(apt => (
apt.readings.map(r => {
const diff = (r.currentValue || 0) - r.previousValue;
if (diff > 40) {
return (
<div key={`${apt.apartmentNumber}-${r.meterId}`} className="flex items-center justify-between p-3 bg-red-50 rounded-xl border border-red-100">
<div className="flex items-center gap-3">
<span className="w-8 h-8 rounded-lg bg-white flex items-center justify-center font-bold text-red-600">Кв.{apt.apartmentNumber}</span>
<div>
<p className="text-xs font-bold text-slate-800">{r.meterType}</p>
<p className="text-[10px] text-red-500">Расход: +{diff} ед.</p>
</div>
</div>
<button className="text-[10px] font-bold text-white bg-red-600 px-3 py-1 rounded-lg">Выписать акт</button>
</div>
);
}
return null;
})
))}
{/* Расхождения по ОДПУ */}
{data.odpuMeters?.filter(m => m.status === 'verified').map(meter => {
const diff = (meter.currentValue || 0) - meter.previousValue;
if (diff > 40) {
return (
<div key={meter.meterId} className="flex items-center justify-between p-3 bg-red-50 rounded-xl border border-red-100">
<div className="flex items-center gap-3">
<span className="w-8 h-8 rounded-lg bg-white flex items-center justify-center font-bold text-red-600">
<Gauge className="w-4 h-4"/>
</span>
<div>
<p className="text-xs font-bold text-slate-800">{meter.meterName}</p>
<p className="text-[10px] text-red-500">Расход: +{diff.toLocaleString('ru-RU')} ед.</p>
</div>
</div>
<button className="text-[10px] font-bold text-white bg-red-600 px-3 py-1 rounded-lg">Выписать акт</button>
</div>
);
}
return null;
})}
{((data.apartments?.filter(a => a.status === 'verified').length || 0) === 0 &&
(data.odpuMeters?.filter(m => m.status === 'verified').length || 0) === 0) && (
<p className="text-sm text-slate-400 text-center py-4">Нет выявленных расхождений</p>
)}
</div>
</div>
</div>
) : (
<>
<div className="flex gap-4 items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text" placeholder={data.roundType === 'odpu' ? "Поиск ПУ..." : data.roundType === 'all' ? "Поиск квартиры или ПУ..." : "Поиск квартиры..."}
value={searchTerm} onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-3 rounded-2xl border border-slate-200 bg-white focus:ring-2 focus:ring-primary-500 outline-none shadow-sm"
/>
</div>
<div className="bg-white px-4 py-3 rounded-2xl border border-slate-200 text-xs font-black text-slate-500 whitespace-nowrap shadow-sm">
{stats.verified} / {stats.total} <span className="text-[10px] text-slate-300 ml-1 uppercase">Готово</span>
</div>
</div>
{/* ОДПУ секция */}
{(data.roundType === 'odpu' || data.roundType === 'all') && data.odpuMeters && data.odpuMeters.length > 0 && (
<div className="mb-6">
<h3 className="font-bold text-slate-800 text-sm mb-3 flex items-center gap-2">
<Gauge className="w-4 h-4 text-primary-500"/> Общедомовые ПУ (ОДПУ)
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{filteredOdpuMeters.map(meter => (
<div
key={meter.meterId}
onClick={() => setSelectedOdpuMeter(meter.meterId)}
className={`bg-white p-4 rounded-3xl border transition-all cursor-pointer relative group ${
meter.status === 'verified' ? 'border-emerald-200 hover:border-emerald-300' :
meter.status === 'no_access' ? 'border-red-200 hover:border-red-300' :
'border-slate-100 hover:border-primary-300 shadow-sm'
}`}
>
<div className="flex justify-between items-start mb-2">
<h4 className="text-lg font-black text-slate-800 leading-none">{meter.meterName}</h4>
{meter.status === 'verified' ? (
<div className="w-6 h-6 rounded-full bg-emerald-100 text-emerald-600 flex items-center justify-center">
<CheckCircle2 className="w-4 h-4"/>
</div>
) : meter.status === 'no_access' ? (
<div className="w-6 h-6 rounded-full bg-red-100 text-red-600 flex items-center justify-center">
<DoorClosed className="w-4 h-4"/>
</div>
) : null}
</div>
<div className="text-xs text-slate-500 mt-2">
<p>Предыдущее: <span className="font-bold text-slate-800">{meter.previousValue.toLocaleString('ru-RU')}</span></p>
{meter.currentValue !== undefined && (
<p className="mt-1">Текущее: <span className="font-bold text-slate-800">{meter.currentValue.toLocaleString('ru-RU')}</span></p>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Квартиры секция */}
{(data.roundType === 'apartments' || data.roundType === 'all') && data.apartments && data.apartments.length > 0 && (
<div>
<h3 className="font-bold text-slate-800 text-sm mb-3 flex items-center gap-2">
<DoorClosed className="w-4 h-4 text-primary-500"/> Индивидуальные ПУ (по квартирам)
</h3>
{/* Группировка по подъездам и этажам, если есть фильтры */}
{(() => {
// Если выбраны подъезды, группируем по подъездам
if (data.selectedEntrances && data.selectedEntrances.length > 0) {
return data.selectedEntrances.sort((a, b) => a - b).map(entranceNum => {
let entranceApts = filteredApts.filter(apt => apt.entranceNumber === entranceNum);
// Если также выбраны этажи, дополнительно фильтруем
if (data.selectedFloors && data.selectedFloors.length > 0) {
entranceApts = entranceApts.filter(apt => apt.floor && data.selectedFloors!.includes(apt.floor));
}
if (entranceApts.length === 0) return null;
// Группируем по этажам внутри подъезда, если выбраны этажи
if (data.selectedFloors && data.selectedFloors.length > 0) {
return (
<div key={entranceNum} className="mb-6">
<h4 className="font-bold text-slate-700 text-sm mb-3 uppercase">Подъезд {entranceNum}</h4>
{data.selectedFloors.sort((a, b) => a - b).map(floorNum => {
const floorApts = entranceApts.filter(apt => apt.floor === floorNum);
if (floorApts.length === 0) return null;
return (
<div key={floorNum} className="mb-4">
<h5 className="font-bold text-slate-600 text-xs mb-2 ml-2">Этаж {floorNum}</h5>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{floorApts.map(apt => (
<div
key={apt.apartmentNumber}
onClick={() => setSelectedApt(apt.apartmentNumber)}
className={`bg-white p-4 rounded-3xl border transition-all cursor-pointer relative group ${
apt.status === 'verified' ? 'border-emerald-200 hover:border-emerald-300' :
apt.status === 'no_access' ? 'border-red-200 hover:border-red-300' :
'border-slate-100 hover:border-primary-300 shadow-sm'
}`}
>
<div className="flex justify-between items-start mb-2">
<h4 className="text-xl font-black text-slate-800 leading-none">Кв. {apt.apartmentNumber}</h4>
{apt.status === 'verified' ? (
<div className="w-6 h-6 rounded-full bg-emerald-100 text-emerald-600 flex items-center justify-center">
<CheckCircle2 className="w-4 h-4"/>
</div>
) : apt.status === 'no_access' ? (
<div className="w-6 h-6 rounded-full bg-red-100 text-red-600 flex items-center justify-center">
<DoorClosed className="w-4 h-4"/>
</div>
) : null}
</div>
<div className="flex gap-1.5 mt-3">
{apt.readings.map(r => {
const Icon = getMeterIcon(r.meterType);
return <div key={r.meterId} className="p-1.5 bg-slate-50 rounded-lg text-slate-400 group-hover:text-primary-500 transition-colors"><Icon className="w-3.5 h-3.5"/></div>
})}
</div>
</div>
))}
</div>
</div>
);
})}
</div>
);
}
// Только подъезды без группировки по этажам
return (
<div key={entranceNum} className="mb-6">
<h4 className="font-bold text-slate-700 text-xs mb-2 uppercase">Подъезд {entranceNum}</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{entranceApts.map(apt => (
<div
key={apt.apartmentNumber}
onClick={() => setSelectedApt(apt.apartmentNumber)}
className={`bg-white p-4 rounded-3xl border transition-all cursor-pointer relative group ${
apt.status === 'verified' ? 'border-emerald-200 hover:border-emerald-300' :
apt.status === 'no_access' ? 'border-red-200 hover:border-red-300' :
'border-slate-100 hover:border-primary-300 shadow-sm'
}`}
>
<div className="flex justify-between items-start mb-2">
<h4 className="text-xl font-black text-slate-800 leading-none">Кв. {apt.apartmentNumber}</h4>
{apt.status === 'verified' ? (
<div className="w-6 h-6 rounded-full bg-emerald-100 text-emerald-600 flex items-center justify-center">
<CheckCircle2 className="w-4 h-4"/>
</div>
) : apt.status === 'no_access' ? (
<div className="w-6 h-6 rounded-full bg-red-100 text-red-600 flex items-center justify-center">
<DoorClosed className="w-4 h-4"/>
</div>
) : null}
</div>
<div className="flex gap-1.5 mt-3">
{apt.readings.map(r => {
const Icon = getMeterIcon(r.meterType);
return <div key={r.meterId} className="p-1.5 bg-slate-50 rounded-lg text-slate-400 group-hover:text-primary-500 transition-colors"><Icon className="w-3.5 h-3.5"/></div>
})}
</div>
</div>
))}
</div>
</div>
);
});
}
// Если выбраны только этажи (без подъездов)
if (data.selectedFloors && data.selectedFloors.length > 0) {
return data.selectedFloors.sort((a, b) => a - b).map(floorNum => {
const floorApts = filteredApts.filter(apt => apt.floor === floorNum);
if (floorApts.length === 0) return null;
return (
<div key={floorNum} className="mb-6">
<h4 className="font-bold text-slate-700 text-xs mb-2 uppercase">Этаж {floorNum}</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{floorApts.map(apt => (
<div
key={apt.apartmentNumber}
onClick={() => setSelectedApt(apt.apartmentNumber)}
className={`bg-white p-4 rounded-3xl border transition-all cursor-pointer relative group ${
apt.status === 'verified' ? 'border-emerald-200 hover:border-emerald-300' :
apt.status === 'no_access' ? 'border-red-200 hover:border-red-300' :
'border-slate-100 hover:border-primary-300 shadow-sm'
}`}
>
<div className="flex justify-between items-start mb-2">
<h4 className="text-xl font-black text-slate-800 leading-none">Кв. {apt.apartmentNumber}</h4>
{apt.status === 'verified' ? (
<div className="w-6 h-6 rounded-full bg-emerald-100 text-emerald-600 flex items-center justify-center">
<CheckCircle2 className="w-4 h-4"/>
</div>
) : apt.status === 'no_access' ? (
<div className="w-6 h-6 rounded-full bg-red-100 text-red-600 flex items-center justify-center">
<DoorClosed className="w-4 h-4"/>
</div>
) : null}
</div>
<div className="flex gap-1.5 mt-3">
{apt.readings.map(r => {
const Icon = getMeterIcon(r.meterType);
return <div key={r.meterId} className="p-1.5 bg-slate-50 rounded-lg text-slate-400 group-hover:text-primary-500 transition-colors"><Icon className="w-3.5 h-3.5"/></div>
})}
</div>
</div>
))}
</div>
</div>
);
});
}
// Нет фильтров - показываем все квартиры
return (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{filteredApts.map(apt => (
<div
key={apt.apartmentNumber}
onClick={() => setSelectedApt(apt.apartmentNumber)}
className={`bg-white p-4 rounded-3xl border transition-all cursor-pointer relative group ${
apt.status === 'verified' ? 'border-emerald-200 hover:border-emerald-300' :
apt.status === 'no_access' ? 'border-red-200 hover:border-red-300' :
'border-slate-100 hover:border-primary-300 shadow-sm'
}`}
>
<div className="flex justify-between items-start mb-2">
<h4 className="text-xl font-black text-slate-800 leading-none">Кв. {apt.apartmentNumber}</h4>
{apt.status === 'verified' ? (
<div className="w-6 h-6 rounded-full bg-emerald-100 text-emerald-600 flex items-center justify-center">
<CheckCircle2 className="w-4 h-4"/>
</div>
) : apt.status === 'no_access' ? (
<div className="w-6 h-6 rounded-full bg-red-100 text-red-600 flex items-center justify-center">
<DoorClosed className="w-4 h-4"/>
</div>
) : null}
</div>
<div className="flex gap-1.5 mt-3">
{apt.readings.map(r => {
const Icon = getMeterIcon(r.meterType);
return <div key={r.meterId} className="p-1.5 bg-slate-50 rounded-lg text-slate-400 group-hover:text-primary-500 transition-colors"><Icon className="w-3.5 h-3.5"/></div>
})}
</div>
</div>
))}
</div>
);
})()}
</div>
)}
</>
)}
{/* ODPU Meter Verification Modal */}
{currentOdpuMeter && (() => {
const meters = building.passport?.meters || [];
const actualMeter = meters[currentOdpuMeter.meterIndex];
const Icon = getMeterIcon(currentOdpuMeter.meterType);
const currentInput = currentOdpuMeter.currentValue || 0;
const diff = currentInput ? currentInput - currentOdpuMeter.previousValue : 0;
const isWarning = diff > 30 || diff < 0;
const unit = actualMeter?.unit || (currentOdpuMeter.meterType === 'Electricity' ? 'кВт⋅ч' : 'м³');
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md animate-fade-in" onClick={() => setSelectedOdpuMeter(null)}>
<div className="bg-white rounded-[2.5rem] w-full max-w-md p-8 shadow-2xl animate-slide-up" onClick={e => e.stopPropagation()}>
<div className="flex justify-between items-start mb-8">
<div>
<h3 className="text-3xl font-black text-slate-900 leading-none">{currentOdpuMeter.meterName}</h3>
<p className="text-xs text-slate-500 mt-2 font-bold uppercase tracking-widest">ОДПУ - Акт сверки показаний</p>
</div>
<button onClick={() => setSelectedOdpuMeter(null)} className="p-2 hover:bg-slate-100 rounded-full"><X className="w-6 h-6 text-slate-300"/></button>
</div>
<div className="space-y-6">
<div className="space-y-3 p-4 bg-slate-50 rounded-2xl border border-slate-100">
<div className="flex justify-between items-center">
<label className="text-[10px] font-black text-slate-400 uppercase flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5 text-primary-500"/> {currentOdpuMeter.meterType}
</label>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-slate-400">Предыдущее: <b className="text-slate-800">{currentOdpuMeter.previousValue}</b></span>
{isWarning && diff > 0 && <AlertTriangle className="w-4 h-4 text-red-500 animate-pulse"/>}
</div>
</div>
<input
type="number"
placeholder="Текущее значение"
defaultValue={currentOdpuMeter.currentValue}
onChange={e => {
const val = Number(e.target.value);
const updated = data.odpuMeters?.map(m =>
m.meterId === currentOdpuMeter.meterId
? { ...m, currentValue: val }
: m
) || [];
setData(prev => ({ ...prev, odpuMeters: updated }));
}}
className={`w-full p-4 bg-white border-2 rounded-2xl text-2xl font-black text-slate-900 outline-none transition-all ${isWarning ? 'border-red-200 focus:border-red-500' : 'border-slate-100 focus:border-primary-500'}`}
/>
{diff > 0 && (
<div className={`flex items-center justify-between text-[10px] font-black uppercase px-1 ${isWarning ? 'text-red-500' : 'text-emerald-500'}`}>
<span>Потребление за период:</span>
<span className="flex items-center gap-1">+{diff.toLocaleString('ru-RU')} {unit} {isWarning ? <TrendingUp className="w-3 h-3"/> : <CheckCircle2 className="w-3 h-3"/>}</span>
</div>
)}
</div>
</div>
<div className="mt-10 grid grid-cols-2 gap-4">
<button
onClick={() => handleUpdateOdpuMeter(currentOdpuMeter.meterId, 'no_access')}
className="py-5 bg-slate-100 text-slate-500 rounded-[1.5rem] font-black text-xs uppercase flex flex-col items-center justify-center gap-1 hover:bg-red-50 hover:text-red-600 transition-all"
>
<DoorClosed className="w-5 h-5"/> Нет доступа
</button>
<button
onClick={() => {
const meter = data.odpuMeters?.find(m => m.meterId === currentOdpuMeter.meterId);
if (meter) {
handleUpdateOdpuMeter(currentOdpuMeter.meterId, 'verified', meter.currentValue);
}
}}
className="py-5 bg-primary-600 text-white rounded-[1.5rem] font-black text-xs uppercase flex flex-col items-center justify-center gap-1 shadow-xl shadow-primary-500/20 active:scale-95 transition-all"
>
<CheckCircle2 className="w-5 h-5"/> Сохранить
</button>
</div>
</div>
</div>
);
})()}
{/* Apartment Verification Modal */}
{currentApt && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md animate-fade-in" onClick={() => setSelectedApt(null)}>
<div className="bg-white rounded-[2.5rem] w-full max-w-md p-8 shadow-2xl animate-slide-up" onClick={e => e.stopPropagation()}>
<div className="flex justify-between items-start mb-8">
<div>
<h3 className="text-3xl font-black text-slate-900 leading-none">Квартира {currentApt.apartmentNumber}</h3>
<p className="text-xs text-slate-500 mt-2 font-bold uppercase tracking-widest">Акт сверки показаний</p>
</div>
<button onClick={() => setSelectedApt(null)} className="p-2 hover:bg-slate-100 rounded-full"><X className="w-6 h-6 text-slate-300"/></button>
</div>
<div className="space-y-6">
{currentApt.readings.map((meter) => {
const Icon = getMeterIcon(meter.meterType);
const currentInput = aptModalValues[meter.meterId] ?? meter.currentValue ?? 0;
const numInput = typeof currentInput === 'number' && !Number.isNaN(currentInput) ? currentInput : 0;
const diff = numInput > 0 ? numInput - meter.previousValue : 0;
const isWarning = diff > 30 || diff < 0;
return (
<div key={meter.meterId} className="space-y-3 p-4 bg-slate-50 rounded-2xl border border-slate-100">
<div className="flex justify-between items-center">
<label className="text-[10px] font-black text-slate-400 uppercase flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5 text-primary-500"/> {meter.meterType}
</label>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-slate-400">Предыдущее: <b className="text-slate-800">{meter.previousValue}</b></span>
{isWarning && diff > 0 && <AlertTriangle className="w-4 h-4 text-red-500 animate-pulse"/>}
</div>
</div>
<input
type="number"
placeholder="Текущее значение"
value={numInput || ''}
onChange={e => {
const val = e.target.value === '' ? 0 : Number(e.target.value);
setAptModalValues(prev => ({ ...prev, [meter.meterId]: val }));
}}
className={`w-full p-4 bg-white border-2 rounded-2xl text-2xl font-black text-slate-900 outline-none transition-all ${isWarning ? 'border-red-200 focus:border-red-500' : 'border-slate-100 focus:border-primary-500'}`}
/>
{diff > 0 && (
<div className={`flex items-center justify-between text-[10px] font-black uppercase px-1 ${isWarning ? 'text-red-500' : 'text-emerald-500'}`}>
<span>Потребление за период:</span>
<span className="flex items-center gap-1">+{diff} ед. {isWarning ? <TrendingUp className="w-3 h-3"/> : <CheckCircle2 className="w-3 h-3"/>}</span>
</div>
)}
</div>
);
})}
</div>
<div className="mt-10 grid grid-cols-2 gap-4">
<button
onClick={() => handleUpdateApt(currentApt.apartmentNumber, 'no_access')}
className="py-5 bg-slate-100 text-slate-500 rounded-[1.5rem] font-black text-xs uppercase flex flex-col items-center justify-center gap-1 hover:bg-red-50 hover:text-red-600 transition-all"
>
<DoorClosed className="w-5 h-5"/> Нет доступа
</button>
<button
onClick={() => {
const readings = currentApt.readings.map(r => ({
...r,
currentValue: aptModalValues[r.meterId] ?? r.currentValue
}));
handleUpdateApt(currentApt.apartmentNumber, 'verified', readings);
}}
className="py-5 bg-primary-600 text-white rounded-[1.5rem] font-black text-xs uppercase flex flex-col items-center justify-center gap-1 shadow-xl shadow-primary-500/20 active:scale-95 transition-all"
>
<CheckCircle2 className="w-5 h-5"/> Сохранить
</button>
</div>
</div>
</div>
)}
</div>
);
};
// Модальное окно выбора типа обхода с фильтрами
const RoundTypeModal: React.FC<{
isOpen: boolean;
onClose: () => void;
onSelect: (type: 'odpu' | 'apartments' | 'all', filters?: { entrances?: number[], floors?: number[] }) => void;
building: Building;
}> = ({ isOpen, onClose, onSelect, building }) => {
const [selectedType, setSelectedType] = useState<'odpu' | 'apartments' | 'all' | null>(null);
const [selectedEntrances, setSelectedEntrances] = useState<number[]>([]);
const [selectedFloors, setSelectedFloors] = useState<number[]>([]);
const entrancesCount = building.passport?.general?.entrancesCount || building.entrances?.length || 0;
const floorsCount = building.passport?.general?.floors || 0;
const toggleEntrance = (num: number) => {
setSelectedEntrances(prev =>
prev.includes(num)
? prev.filter(n => n !== num)
: [...prev, num]
);
};
const toggleFloor = (num: number) => {
setSelectedFloors(prev =>
prev.includes(num)
? prev.filter(n => n !== num)
: [...prev, num]
);
};
const handleConfirm = () => {
if (!selectedType) return;
const filters: { entrances?: number[], floors?: number[] } = {};
if (selectedEntrances.length > 0) filters.entrances = selectedEntrances;
if (selectedFloors.length > 0) filters.floors = selectedFloors;
onSelect(selectedType, Object.keys(filters).length > 0 ? filters : undefined);
onClose();
// Сброс
setSelectedType(null);
setSelectedEntrances([]);
setSelectedFloors([]);
};
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">
<h3 className="text-xl font-bold text-slate-800">Выберите тип обхода</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-full">
<X className="w-5 h-5 text-slate-500"/>
</button>
</div>
<div className="space-y-4">
{/* Выбор типа обхода */}
<div>
<label className="text-xs font-bold text-slate-500 uppercase block mb-2">Тип обхода</label>
<div className="space-y-2">
<button
onClick={() => setSelectedType('odpu')}
className={`w-full p-4 border-2 rounded-xl transition-all text-left ${
selectedType === 'odpu'
? 'bg-primary-50 border-primary-500'
: 'bg-slate-50 border-slate-200 hover:border-primary-300'
}`}
>
<div className="flex items-center gap-3">
<div className="p-2 bg-primary-100 rounded-lg">
<Gauge className="w-5 h-5 text-primary-600"/>
</div>
<div>
<p className="font-bold text-slate-800">Только общедомовые ПУ (ОДПУ)</p>
<p className="text-xs text-slate-500 mt-1">Обход только общедомовых приборов учета</p>
</div>
</div>
</button>
<button
onClick={() => setSelectedType('apartments')}
className={`w-full p-4 border-2 rounded-xl transition-all text-left ${
selectedType === 'apartments'
? 'bg-primary-50 border-primary-500'
: 'bg-slate-50 border-slate-200 hover:border-primary-300'
}`}
>
<div className="flex items-center gap-3">
<div className="p-2 bg-primary-100 rounded-lg">
<DoorClosed className="w-5 h-5 text-primary-600"/>
</div>
<div>
<p className="font-bold text-slate-800">По квартирно (индивидуальные ПУ)</p>
<p className="text-xs text-slate-500 mt-1">Обход индивидуальных приборов учета по квартирам</p>
</div>
</div>
</button>
<button
onClick={() => setSelectedType('all')}
className={`w-full p-4 border-2 rounded-xl transition-all text-left ${
selectedType === 'all'
? 'bg-primary-50 border-primary-500'
: 'bg-slate-50 border-slate-200 hover:border-primary-300'
}`}
>
<div className="flex items-center gap-3">
<div className="p-2 bg-primary-100 rounded-lg">
<ClipboardCheck className="w-5 h-5 text-primary-600"/>
</div>
<div>
<p className="font-bold text-slate-800">Все счетчики</p>
<p className="text-xs text-slate-500 mt-1">Обход всех приборов учета (ОДПУ + индивидуальные)</p>
</div>
</div>
</button>
</div>
</div>
{/* Фильтры (только для квартир) */}
{selectedType === 'apartments' || selectedType === 'all' ? (
<>
{entrancesCount > 0 && (
<div>
<label className="text-xs font-bold text-slate-500 uppercase block mb-2">
Подъезды (оставьте пустым для всех)
</label>
<div className="flex flex-wrap gap-2">
{Array.from({ length: entrancesCount }, (_, i) => i + 1).map(num => (
<button
key={num}
onClick={() => toggleEntrance(num)}
className={`px-4 py-2 rounded-lg font-bold text-sm transition-all ${
selectedEntrances.includes(num)
? 'bg-primary-600 text-white shadow-lg'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{num}
</button>
))}
</div>
</div>
)}
{floorsCount > 0 && (
<div>
<label className="text-xs font-bold text-slate-500 uppercase block mb-2">
Этажи (оставьте пустым для всех)
</label>
<div className="flex flex-wrap gap-2">
{Array.from({ length: floorsCount }, (_, i) => i + 1).map(num => (
<button
key={num}
onClick={() => toggleFloor(num)}
className={`px-4 py-2 rounded-lg font-bold text-sm transition-all ${
selectedFloors.includes(num)
? 'bg-primary-600 text-white shadow-lg'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{num}
</button>
))}
</div>
</div>
)}
</>
) : null}
{/* Кнопка подтверждения */}
<div className="flex gap-3 pt-4 border-t border-slate-200">
<button
onClick={handleConfirm}
disabled={!selectedType}
className="flex-1 bg-primary-600 text-white px-6 py-3 rounded-xl font-bold text-sm hover:bg-primary-700 transition-all disabled:bg-slate-300 disabled:cursor-not-allowed"
>
Создать обход
</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>
);
};
export const MeterCheckView: React.FC<{
building: Building,
setBuilding: React.Dispatch<React.SetStateAction<Building>>
}> = ({ building, setBuilding }) => {
const [activeRoundId, setActiveRoundId] = useState<string | null>(null);
const [showRoundTypeModal, setShowRoundTypeModal] = useState(false);
const handleCreateRound = (roundType: 'odpu' | 'apartments' | 'all', filters?: { entrances?: number[], floors?: number[] }) => {
const meters = building.passport?.meters || [];
const odpuMeters = meters.filter(m => m.hasMeter).map((meter, idx) => {
const lastReading = meter.readings && meter.readings.length > 0
? meter.readings[0].value
: (meter.currentReading || 0);
return {
meterId: `odpu-${idx}`,
meterIndex: meters.findIndex(m => m === meter),
meterType: meter.resource || 'Other',
meterName: meter.name || meter.resource || 'ПУ',
previousValue: lastReading,
status: 'pending' as const,
};
});
// Фильтруем квартиры по подъездам и этажам
let filteredAccounts = building.accounts;
// Функция для определения подъезда квартиры
const getEntranceForApartment = (acc: any): number | undefined => {
// Сначала проверяем, есть ли явное поле entranceNumber
if (acc.entranceNumber) return acc.entranceNumber;
// Пытаемся найти в building.entrances
const entrance = building.entrances?.find(e =>
e.sections?.some(s => s.elements.some(el => el.name.includes(acc.apartmentNumber)))
);
if (entrance) return entrance.number;
// Если есть несколько подъездов, пытаемся определить по номеру квартиры
// (обычно квартиры распределяются по подъездам примерно равномерно)
const entrancesCount = building.passport?.general?.entrancesCount || building.entrances?.length || 0;
if (entrancesCount > 0) {
// Простая эвристика: делим квартиры по подъездам
const aptNum = parseInt(acc.apartmentNumber) || 0;
if (aptNum > 0) {
// Предполагаем, что квартиры распределены примерно равномерно
// Например, если 3 подъезда и квартиры 1-90, то подъезд 1 = 1-30, подъезд 2 = 31-60, подъезд 3 = 61-90
const totalApts = building.accounts.length;
const aptsPerEntrance = Math.ceil(totalApts / entrancesCount);
const calculatedEntrance = Math.ceil(aptNum / aptsPerEntrance);
if (calculatedEntrance >= 1 && calculatedEntrance <= entrancesCount) {
return calculatedEntrance;
}
}
}
return undefined;
};
if (filters?.entrances && filters.entrances.length > 0) {
filteredAccounts = filteredAccounts.filter(acc => {
const entranceNum = getEntranceForApartment(acc);
return entranceNum && filters.entrances!.includes(entranceNum);
});
}
if (filters?.floors && filters.floors.length > 0) {
filteredAccounts = filteredAccounts.filter(acc =>
acc.floor && filters.floors!.includes(acc.floor)
);
}
const apartments = filteredAccounts.map(acc => ({
apartmentNumber: acc.apartmentNumber,
entranceNumber: getEntranceForApartment(acc),
floor: acc.floor || undefined,
status: 'pending' as const,
readings: acc.meters.map(m => ({
meterId: m.id,
meterType: m.type,
previousValue: m.readings[0]?.value || 0
}))
}));
const newRound: MeterCheckRound = {
id: `round-${Date.now()}`,
date: new Date().toLocaleDateString('ru-RU'),
inspector: 'Алексей Петров',
status: 'active',
buildingId: building.id,
roundType,
selectedEntrances: filters?.entrances,
selectedFloors: filters?.floors,
...(roundType === 'odpu' || roundType === 'all' ? { odpuMeters } : {}),
...(roundType === 'apartments' || roundType === 'all' ? { apartments } : {}),
};
const next: Building = {
...building,
meterCheckRounds: [newRound, ...(building.meterCheckRounds || [])]
};
setBuilding(next);
persistBuilding(next);
setActiveRoundId(newRound.id);
};
const handleUpdateRound = (updatedRound: MeterCheckRound) => {
let next: Building;
if (updatedRound.status === 'completed') {
next = applyRoundReadingsToBuilding(building, updatedRound);
} else {
next = {
...building,
meterCheckRounds: (building.meterCheckRounds || []).map(r =>
r.id === updatedRound.id ? updatedRound : r
)
};
}
setBuilding(next);
persistBuilding(next);
setActiveRoundId(null);
};
const activeRound = building.meterCheckRounds?.find(r => r.id === activeRoundId);
if (activeRound) {
return <RoundDetails round={activeRound} onSave={handleUpdateRound} onBack={() => setActiveRoundId(null)} building={building} />;
}
return (
<div className="space-y-6 animate-fade-in">
<div className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-sm relative overflow-hidden group">
<div className="absolute top-0 right-0 p-10 opacity-5 rotate-12 group-hover:rotate-0 transition-transform">
<ShieldAlert className="w-40 h-40 text-primary-600"/>
</div>
<div className="relative z-10 max-w-md">
<h3 className="text-2xl font-black text-slate-800 mb-2">Обходы и Контроль ПУ</h3>
<p className="text-xs text-slate-500 font-medium mb-6">Инструмент для выявления занижений показаний и контроля состояния индивидуальных приборов учета.</p>
<button
onClick={() => setShowRoundTypeModal(true)}
className="bg-primary-600 text-white px-6 py-3 rounded-2xl font-black text-xs uppercase flex items-center gap-3 shadow-xl shadow-primary-500/20 active:scale-95 transition-all"
>
<ClipboardCheck className="w-5 h-5"/> Сформировать новый обход
</button>
<RoundTypeModal
isOpen={showRoundTypeModal}
onClose={() => setShowRoundTypeModal(false)}
onSelect={handleCreateRound}
building={building}
/>
</div>
</div>
<div className="space-y-4">
<h3 className="font-black text-slate-500 text-[10px] uppercase tracking-[0.2em] px-1">Архив обходов и сверок</h3>
{(!building.meterCheckRounds || building.meterCheckRounds.length === 0) && (
<div className="py-20 bg-slate-50 rounded-[2rem] border-2 border-dashed border-slate-200 flex flex-col items-center justify-center text-slate-400">
<RotateCcw className="w-12 h-12 mb-4 opacity-20"/>
<p className="text-sm font-bold uppercase tracking-widest">Нет записей</p>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{building.meterCheckRounds?.map(round => {
const totalCount = (round.apartments?.length || 0) + (round.odpuMeters?.length || 0);
const verifiedCount = (round.apartments?.filter(a => a.status === 'verified').length || 0) + (round.odpuMeters?.filter(m => m.status === 'verified').length || 0);
const progress = totalCount > 0 ? (verifiedCount / totalCount) * 100 : 0;
const roundTypeLabel = round.roundType === 'odpu' ? 'ОДПУ' :
round.roundType === 'apartments' ? 'Квартиры' :
'Все';
return (
<div
key={round.id}
onClick={() => setActiveRoundId(round.id)}
className="bg-white p-5 rounded-3xl border border-slate-200 shadow-sm hover:shadow-lg hover:border-primary-200 transition-all cursor-pointer group"
>
<div className="flex justify-between items-start mb-4">
<div className="p-3 rounded-2xl bg-slate-50 text-slate-400 group-hover:bg-primary-50 group-hover:text-primary-600 transition-colors">
<ClipboardCheck className="w-6 h-6"/>
</div>
<span className={`text-[10px] font-black px-2 py-1 rounded-lg uppercase ${round.status === 'completed' ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600 animate-pulse'}`}>
{round.status === 'completed' ? 'Завершен' : 'В процессе'}
</span>
</div>
<h4 className="font-black text-slate-800 text-lg leading-tight">Сверка от {round.date}</h4>
<div className="flex items-center gap-2 mt-1 text-xs text-slate-400 font-bold">
<User className="w-3 h-3"/> {round.inspector}
</div>
<div className="mt-2 flex flex-wrap gap-1">
<span className="text-[10px] font-bold text-primary-600 bg-primary-50 px-2 py-0.5 rounded uppercase">{roundTypeLabel}</span>
{round.selectedEntrances && round.selectedEntrances.length > 0 && (
<span className="text-[10px] font-bold text-slate-600 bg-slate-100 px-2 py-0.5 rounded">
Подъезды: {round.selectedEntrances.sort((a, b) => a - b).join(', ')}
</span>
)}
{round.selectedFloors && round.selectedFloors.length > 0 && (
<span className="text-[10px] font-bold text-slate-600 bg-slate-100 px-2 py-0.5 rounded">
Этажи: {round.selectedFloors.sort((a, b) => a - b).join(', ')}
</span>
)}
</div>
<div className="mt-6 space-y-2">
<div className="flex justify-between items-end text-[10px] font-black uppercase text-slate-400">
<span>Прогресс</span>
<span className="text-slate-700">{verifiedCount} / {totalCount}</span>
</div>
<div className="h-1.5 w-full bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-primary-500 transition-all duration-1000" style={{ width: `${progress}%` }}/>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};