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