Files
mkd/components/BuildingCharacteristics.tsx

299 lines
14 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
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>
);
};