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

1172 lines
75 KiB
TypeScript
Executable File
Raw Permalink Blame History

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