299 lines
14 KiB
TypeScript
299 lines
14 KiB
TypeScript
|
|
|
|||
|
|
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>
|
|||
|
|
);
|
|||
|
|
};
|