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

299 lines
14 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Building, District } from '../types';
import {
ArrowLeft,
Pencil,
Save,
X,
LayoutDashboard,
Gauge,
CalendarDays,
Package,
Camera,
Banknote,
Users,
FileText,
MapPin
} from 'lucide-react';
import { storageService } from '../services/storageService';
import { backendApi } from '../services/apiClient';
import { readCache, saveCache } from '../hooks/useCachedFetch';
import { REFRESH_EVENTS } from '../constants/refreshEvents';
// Modular Sheet Imports
import { Overview } from './building/Overview';
import { MeterCheck } from './building/MeterCheck';
import { WorkPlan } from './building/WorkPlan';
import { Supply } from './building/Supply';
import { Inspections } from './building/Inspections';
import { Finance } from './building/Finance';
import { Accounts } from './building/Accounts';
import { Passport } from './building/Passport';
interface Props {
building: Building;
onBack: () => void;
onBuildingUpdate?: (building: Building) => void;
}
type TabType = 'overview' | 'meterCheck' | 'workPlan' | 'supply' | 'inspections' | 'finance' | 'accounts' | 'passport';
const TABS: { id: TabType; label: string; icon: any }[] = [
{ id: 'overview', label: 'Сводка', icon: LayoutDashboard },
{ id: 'meterCheck', label: 'Сверка ПУ', icon: Gauge },
{ id: 'workPlan', label: 'План работ', icon: CalendarDays },
{ id: 'supply', label: 'Снабжение', icon: Package },
{ id: 'inspections', label: 'Осмотры', icon: Camera },
{ id: 'finance', label: 'Финансы', icon: Banknote },
{ id: 'accounts', label: 'Счета', icon: Users },
{ id: 'passport', label: 'Паспорт', icon: FileText },
];
const BUILDING_TABS: TabType[] = ['overview', 'meterCheck', 'workPlan', 'supply', 'inspections', 'finance', 'accounts', 'passport'];
const SUBTAB_KEY = 'mkd_subTab_building';
export const BuildingCharacteristics: React.FC<Props> = ({ building: initialBuilding, onBack, onBuildingUpdate }) => {
const [building, setBuildingState] = useState<Building>(initialBuilding);
const setBuilding = useCallback((next: Building | ((prev: Building) => Building)) => {
setBuildingState(prev => {
const nextBuilding = typeof next === 'function' ? next(prev) : next;
onBuildingUpdate?.(nextBuilding);
return nextBuilding;
});
}, [onBuildingUpdate]);
const [originalBuilding, setOriginalBuilding] = useState<Building>(initialBuilding);
const [activeTab, setActiveTab] = useState<TabType>(() => {
const s = localStorage.getItem(SUBTAB_KEY);
return (s && BUILDING_TABS.includes(s as TabType)) ? s as TabType : 'overview';
});
useEffect(() => {
localStorage.setItem(SUBTAB_KEY, activeTab);
}, [activeTab]);
const [isEditing, setIsEditing] = useState(false);
const [districts, setDistricts] = useState<District[]>([]);
// Синхронизируем состояние только при смене дома (по id), чтобы избежать бесконечного цикла
// при перерисовке родителя с новым объектом initialBuilding.
// Кеш лицевых счетов: при открытии дома показываем из кеша для мгновенного отображения.
useEffect(() => {
const buildingId = initialBuilding.id;
const globalFields = storageService.getGlobalPassportFields();
const fromStorage = storageService.getBuildingById(buildingId);
const cachedBuilding = readCache<Building | null>(`mkd_building_${buildingId}`, null);
let updatedBuilding: Building = {
...initialBuilding,
tasks: Array.isArray(fromStorage?.tasks) ? fromStorage.tasks : (initialBuilding.tasks || []),
annualPlan: Array.isArray(fromStorage?.annualPlan) ? fromStorage.annualPlan : (initialBuilding.annualPlan || []),
};
if (cachedBuilding?.accounts?.length) {
updatedBuilding.accounts = cachedBuilding.accounts;
}
if (!updatedBuilding.passport.odpu) {
updatedBuilding.passport.odpu = { customFields: {} };
}
(['general', 'construction', 'engineering', 'odpu', 'land', 'management'] as const).forEach(section => {
if (globalFields[section]) {
const sectionData = updatedBuilding.passport[section] as any;
if (!sectionData) {
updatedBuilding.passport[section] = { customFields: {} } as any;
return;
}
const customFields = { ...(sectionData.customFields || {}) };
Object.keys(globalFields[section]).forEach(fieldName => {
if (!customFields[fieldName]) {
customFields[fieldName] = { ...globalFields[section][fieldName] };
} else {
customFields[fieldName] = {
...customFields[fieldName],
type: globalFields[section][fieldName].type,
files: customFields[fieldName].files || []
};
}
});
updatedBuilding.passport[section] = { ...sectionData, customFields } as any;
}
});
setBuildingState(updatedBuilding);
setOriginalBuilding(updatedBuilding);
// Зависимость только по id — эффект не дергается при каждой перерисовке родителя
}, [initialBuilding.id]);
// Загружаем список участков для выбора
useEffect(() => {
const loadDistricts = async () => {
try {
const allDistricts = await backendApi.getDistricts();
setDistricts(allDistricts);
} catch (error) {
console.warn('Failed to load districts, using local storage:', error);
setDistricts(storageService.getDistricts());
}
};
loadDistricts();
}, []);
// Реестр лицевых счетов: кеш, polling 30 сек, dispatch при изменении
const buildingId = building.id;
const fetchBuilding = useCallback(async () => {
try {
const fresh = await backendApi.getBuilding(buildingId);
const fromStorage = storageService.getBuildingById(buildingId);
const merged: Building = {
...fresh,
tasks: Array.isArray(fromStorage?.tasks) ? fromStorage.tasks : (fresh.tasks || []),
annualPlan: Array.isArray(fromStorage?.annualPlan) ? fromStorage.annualPlan : (fresh.annualPlan || []),
};
setBuilding(merged);
saveCache(`mkd_building_${buildingId}`, merged);
} catch (e) {
console.warn('[BuildingCharacteristics] Failed to refresh building:', e);
}
}, [buildingId, setBuilding]);
useEffect(() => {
const onRefresh = () => fetchBuilding();
window.addEventListener(REFRESH_EVENTS.buildingAccounts, onRefresh);
return () => window.removeEventListener(REFRESH_EVENTS.buildingAccounts, onRefresh);
}, [fetchBuilding]);
useEffect(() => {
const interval = setInterval(() => fetchBuilding(), 10 * 1000);
return () => clearInterval(interval);
}, [fetchBuilding]);
// Автосохранение при изменениях (план работ, осмотры и т.д.) — в storage и в БД
const skipInitialPersist = useRef(true);
useEffect(() => {
if (skipInitialPersist.current) {
skipInitialPersist.current = false;
return;
}
const t = setTimeout(() => {
const toSave = { ...building, isDirty: true };
storageService.saveBuildingData(toSave);
backendApi.updateBuilding(toSave).catch((err) => {
console.warn('[BuildingCharacteristics] Auto-save to backend failed:', err);
});
}, 1000);
return () => clearTimeout(t);
}, [building]);
const handleSave = async () => {
const newBuilding = { ...building, isDirty: true };
setBuilding(newBuilding);
setOriginalBuilding(newBuilding);
storageService.saveBuildingData(newBuilding);
setIsEditing(false);
// Дополнительно отправляем в БД (паспорт и прочие изменения)
try {
await backendApi.updateBuilding(newBuilding);
} catch (error) {
console.error('[BuildingCharacteristics] Failed to sync building to backend:', error);
// UI продолжит работать, данные останутся хотя бы в localStorage
}
};
const handleCancel = () => {
setBuilding(originalBuilding);
setIsEditing(false);
};
return (
<div className="space-y-6 animate-fade-in pb-24">
{/* Header Panel */}
<div className="sticky top-0 z-30 bg-slate-50/95 backdrop-blur-md pt-2 pb-4 border-b border-slate-200 -mx-4 px-4 md:-mx-8 md:px-8 flex justify-between items-center">
<div className="flex items-center gap-3 max-w-[70%]">
<button
onClick={onBack}
className="p-2 -ml-2 rounded-full hover:bg-slate-200 text-slate-600 transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="overflow-hidden">
{isEditing ? (
<div className="space-y-2">
<input
value={building.passport.address}
onChange={(e) => setBuilding({...building, passport: {...building.passport, address: e.target.value}})}
className="text-lg font-black text-slate-900 w-full bg-white border border-primary-300 rounded px-2 py-1 outline-none focus:ring-2 focus:ring-primary-500/20"
/>
<div className="flex items-center gap-2">
<MapPin className="w-3.5 h-3.5 text-slate-400" />
<select
value={building.districtId}
onChange={(e) => setBuilding({...building, districtId: e.target.value})}
className="text-xs font-bold text-slate-700 bg-white border border-slate-300 rounded px-2 py-1 outline-none focus:ring-2 focus:ring-primary-500/20"
>
{districts.map(district => (
<option key={district.id} value={district.id}>
{district.name}
</option>
))}
</select>
</div>
</div>
) : (
<>
<h1 className="text-lg font-black text-slate-900 truncate leading-tight">{building.passport.address}</h1>
<div className="flex items-center gap-1.5 mt-0.5">
<MapPin className="w-3 h-3 text-slate-400" />
<span className="text-[10px] text-slate-500 font-bold">
{districts.find(d => d.id === building.districtId)?.name || 'Участок не указан'}
</span>
</div>
</>
)}
<div className="flex items-center gap-2 mt-0.5">
<span className={`text-[10px] font-black uppercase px-1.5 py-0.5 rounded ${isEditing ? 'bg-amber-100 text-amber-600' : 'bg-slate-200 text-slate-500'}`}>
{isEditing ? 'Правка данных' : 'Режим просмотра'}
</span>
<span className="text-[10px] text-slate-400 font-bold uppercase">{TABS.find(t => t.id === activeTab)?.label}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{isEditing ? (
<>
<button onClick={handleCancel} className="p-2.5 rounded-xl bg-white text-slate-400 border border-slate-200 hover:text-red-500 transition-all shadow-sm" title="Отмена"><X className="w-5 h-5" /></button>
<button onClick={handleSave} className="p-2.5 rounded-xl bg-emerald-600 text-white shadow-lg shadow-emerald-500/30 hover:bg-emerald-700 transition-all" title="Сохранить"><Save className="w-5 h-5" /></button>
</>
) : (
<button onClick={() => setIsEditing(true)} className="p-2.5 rounded-xl bg-white border border-slate-200 text-slate-500 hover:text-primary-600 transition-all shadow-sm" title="Редактировать"><Pencil className="w-5 h-5" /></button>
)}
</div>
</div>
{/* Sheet Selector (Excel-style bottom tabs look) */}
<div className="flex p-1 bg-slate-200/50 rounded-2xl overflow-x-auto no-scrollbar gap-1">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex-shrink-0 min-w-[7rem] flex items-center justify-center gap-2 px-4 py-2.5 text-[10px] font-black uppercase tracking-wider whitespace-nowrap rounded-xl transition-all ${activeTab === tab.id ? 'bg-white text-primary-600 shadow-sm border border-white' : 'text-slate-500 hover:text-slate-700'}`}
>
<tab.icon className="w-3.5 h-3.5" />
{tab.label}
</button>
))}
</div>
{/* Dynamic Content Area */}
<div className="min-h-[280px] sm:min-h-[360px] md:min-h-[500px]">
{activeTab === 'overview' && <Overview building={building} onNavigate={setActiveTab} setBuilding={setBuilding} />}
{activeTab === 'meterCheck' && <MeterCheck building={building} setBuilding={setBuilding} />}
{activeTab === 'workPlan' && <WorkPlan building={building} setBuilding={setBuilding} isEditing={isEditing} />}
{activeTab === 'supply' && <Supply building={building} setBuilding={setBuilding} isEditing={isEditing} />}
{activeTab === 'inspections' && <Inspections building={building} setBuilding={setBuilding} isEditing={isEditing} />}
{activeTab === 'finance' && <Finance building={building} setBuilding={setBuilding} isEditing={isEditing} />}
{activeTab === 'accounts' && <Accounts building={building} setBuilding={setBuilding} />}
{activeTab === 'passport' && <Passport building={building} isEditing={isEditing} setBuilding={setBuilding} />}
</div>
</div>
);
};