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

895 lines
50 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, useRef, useEffect, useMemo } from 'react';
import { Building, InspectionAct, InspectionItem, InspectionSection, InspectionCommonSection, InspectionMKDElement } from '../../types';
import { buildFloorElements, buildEntranceElements, buildCommonSectionElements } from '../../inspectionElementTemplates';
import { DynamicInspectionSections } from './DynamicInspectionSections';
import { storageService } from '../../services/storageService';
import { backendApi } from '../../services/apiClient';
import { connectionService, type ConnectionStatus } from '../../services/connectionService';
import {
Plus,
AlertCircle,
CheckCircle2,
Calendar,
ChevronRight,
ChevronDown,
ChevronUp,
Wrench,
Save,
X,
ArrowLeft,
MapPin,
FileText,
Layers,
AlertTriangle,
Info,
Camera,
PenTool,
Trash2,
Eraser,
Image as ImageIcon,
Paperclip,
History,
WifiOff,
Gauge,
DoorClosed,
Building2
} from 'lucide-react';
// html2canvas and jsPDF loaded on demand in handleGeneratePDF to keep initial bundle small
const InspectionForm: React.FC<{ act: InspectionAct, onSave: (act: InspectionAct) => void, onBack: () => void, building: Building, setBuilding: React.Dispatch<React.SetStateAction<Building>> }> = ({ act, onSave, onBack, building, setBuilding }) => {
const [formData, setFormData] = useState<InspectionAct>(act);
const [activeSection, setActiveSection] = useState<'meta' | 'dynamic' | 'structure' | 'engineering' | 'media' | 'summary'>(
(formData.sections && formData.sections.length > 0) || (formData.commonSections && formData.commonSections.length > 0) ? 'dynamic' : 'meta'
);
const [isGeoLoading, setIsGeoLoading] = useState(false);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>(connectionService.getStatus());
const [isMobile, setIsMobile] = useState(false);
// Canvas Logic
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
// Определяем мобильное устройство
useEffect(() => {
const checkMobile = () => {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
const isMobileDevice = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent.toLowerCase());
const isSmallScreen = window.innerWidth < 768;
setIsMobile(isMobileDevice || isSmallScreen);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// Подписка на изменения статуса подключения
useEffect(() => {
const unsubscribe = connectionService.subscribe((status) => {
setConnectionStatus(status);
});
return () => unsubscribe();
}, []);
// Initial Checklist Population if empty
useEffect(() => {
if (!formData.checklist || formData.checklist.length === 0) {
const initialItems: InspectionItem[] = [
// Structural
{ id: 'str-1', category: 'structural', label: 'Фундамент и отмостка', status: 'not_checked' },
{ id: 'str-2', category: 'structural', label: 'Подвал (сухость, свет)', status: 'not_checked' },
{ id: 'str-3', category: 'structural', label: 'Фасад и швы', status: 'not_checked' },
{ id: 'str-4', category: 'structural', label: 'Кровля и примыкания', status: 'not_checked' },
{ id: 'str-5', category: 'structural', label: 'Входные группы', status: 'not_checked' },
// Engineering
{ id: 'eng-1', category: 'engineering', label: 'ИТП (Отопление)', status: 'not_checked' },
{ id: 'eng-2', category: 'engineering', label: 'ХВС / ГВС (Стояки)', status: 'not_checked' },
{ id: 'eng-3', category: 'engineering', label: 'Электроснабжение', status: 'not_checked' },
{ id: 'eng-4', category: 'engineering', label: 'Лифтовое оборудование', status: 'not_checked' },
];
setFormData(prev => ({ ...prev, checklist: initialItems }));
}
}, []);
// Load existing signature if available
useEffect(() => {
if (activeSection === 'summary' && formData.signatureUrl && canvasRef.current) {
const ctx = canvasRef.current.getContext('2d');
const img = new Image();
img.onload = () => {
ctx?.drawImage(img, 0, 0);
};
img.src = formData.signatureUrl;
}
}, [activeSection]);
// Canvas Drawing Handlers
const startDrawing = (e: React.MouseEvent | React.TouchEvent) => {
setIsDrawing(true);
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const x = ('touches' in e) ? e.touches[0].clientX - rect.left : (e as React.MouseEvent).nativeEvent.offsetX;
const y = ('touches' in e) ? e.touches[0].clientY - rect.top : (e as React.MouseEvent).nativeEvent.offsetY;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.strokeStyle = '#000';
};
const draw = (e: React.MouseEvent | React.TouchEvent) => {
if (!isDrawing) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Prevent scrolling when drawing on touch devices
if ('touches' in e) e.preventDefault();
const rect = canvas.getBoundingClientRect();
const x = ('touches' in e) ? e.touches[0].clientX - rect.left : (e as React.MouseEvent).nativeEvent.offsetX;
const y = ('touches' in e) ? e.touches[0].clientY - rect.top : (e as React.MouseEvent).nativeEvent.offsetY;
ctx.lineTo(x, y);
ctx.stroke();
};
const stopDrawing = () => {
if (isDrawing) {
setIsDrawing(false);
const canvas = canvasRef.current;
if (canvas) {
const dataUrl = canvas.toDataURL();
setFormData(prev => ({ ...prev, signatureUrl: dataUrl }));
}
}
};
const clearSignature = () => {
const canvas = canvasRef.current;
if (canvas) {
const ctx = canvas.getContext('2d');
ctx?.clearRect(0, 0, canvas.width, canvas.height);
setFormData(prev => ({ ...prev, signatureUrl: undefined }));
}
};
const handleUpdateItem = (id: string, updates: Partial<InspectionItem>) => {
setFormData(prev => {
const currentChecklist = prev.checklist || [];
return {
...prev,
checklist: currentChecklist.map(item => item.id === id ? { ...item, ...updates } : item)
};
});
};
const handleAddItemPhoto = (itemId: string) => {
const mockPhotoUrl = `https://picsum.photos/400/300?random=${Math.random()}`;
setFormData(prev => {
const checklist = prev.checklist || [];
return {
...prev,
checklist: checklist.map(item => {
if (item.id !== itemId) return item;
const photos = item.photos || [];
return { ...item, photos: [...photos, mockPhotoUrl] };
})
};
});
};
const handleRemoveItemPhoto = (itemId: string, photoIdx: number) => {
setFormData(prev => {
const checklist = prev.checklist || [];
return {
...prev,
checklist: checklist.map(item => {
if (item.id !== itemId) return item;
const photos = item.photos || [];
return { ...item, photos: photos.filter((_, i) => i !== photoIdx) };
})
};
});
};
const handleGetGeo = () => {
setIsGeoLoading(true);
setTimeout(() => {
setFormData(prev => ({
...prev,
geolocation: { lat: 55.7558, lng: 37.6173, timestamp: new Date().toISOString() }
}));
setIsGeoLoading(false);
}, 1500);
};
const handleGeneratePDF = async () => {
setIsGeneratingPdf(true);
await new Promise(resolve => setTimeout(resolve, 100));
const input = document.getElementById('printable-act');
if (input) {
try {
const html2canvas = (await import('html2canvas')).default;
const { jsPDF } = await import('jspdf');
const canvas = await html2canvas(input, { scale: 2, useCORS: true, logging: false });
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF('p', 'mm', 'a4');
const imgWidth = canvas.width;
const imgHeight = canvas.height;
const finalImgWidth = 190;
const finalImgHeight = (imgHeight * finalImgWidth) / imgWidth;
pdf.setFontSize(18);
pdf.text("Акт осмотра МКД", 105, 20, { align: "center" });
pdf.addImage(imgData, 'PNG', 10, 30, finalImgWidth, finalImgHeight);
pdf.save(`Act_${formData.number}.pdf`);
} catch (err) {
console.error("PDF Gen Error", err);
alert("Ошибка генерации PDF");
}
}
setIsGeneratingPdf(false);
};
const issuesCount = (formData.checklist || []).filter(i => i.status === 'repair_needed').length;
return (
<div className="animate-fade-in bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden relative">
{/* HIDDEN PRINTABLE AREA FOR PDF GENERATION */}
<div id="printable-act" className="absolute left-[-9999px] top-0 w-[800px] bg-white p-8 text-black font-sans">
<div className="border-b-2 border-black pb-4 mb-6">
<h1 className="text-2xl font-bold uppercase mb-2">Акт осмотра общего имущества</h1>
<div className="flex justify-between text-sm">
<span> {formData.number}</span>
<span>Дата: {formData.date}</span>
</div>
</div>
<div className="space-y-6">
<div className="text-sm">
<p><strong>Основание:</strong> {formData.basis || 'Плановый осмотр'}</p>
<p><strong>Комиссия:</strong> {formData.commissionMembers}</p>
<p><strong>Присутствовал:</strong> {formData.ownerRepresentative}</p>
</div>
<div>
<h2 className="text-lg font-bold border-b border-gray-300 mb-2">1. Результаты осмотра</h2>
<table className="w-full text-sm border-collapse border border-gray-300">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 p-2 text-left">Элемент</th>
<th className="border border-gray-300 p-2 text-center">Статус</th>
<th className="border border-gray-300 p-2 text-left">Примечание</th>
</tr>
</thead>
<tbody>
{(formData.checklist || []).map(item => (
<tr key={item.id}>
<td className="border border-gray-300 p-2">{item.label}</td>
<td className="border border-gray-300 p-2 text-center">
{item.status === 'ok' ? 'Норма' : item.status === 'repair_needed' ? 'Дефект' : '-'}
</td>
<td className="border border-gray-300 p-2">
{item.description} {item.wearPercent ? `(Износ ${item.wearPercent}%)` : ''}
{item.photos && item.photos.length > 0 && ` [Фотоотчет: ${item.photos.length} шт.]`}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<h2 className="text-lg font-bold border-b border-gray-300 mb-2">2. Заключение</h2>
<p className="text-sm"><strong>Всего дефектов:</strong> {issuesCount}</p>
<p className="text-sm"><strong>Решение:</strong> {formData.workCategory === 'maintenance' ? 'Требуется текущий ремонт' : formData.workCategory === 'capital' ? 'Требуется капитальный ремонт' : 'Удовлетворительно'}</p>
<p className="text-sm"><strong>Готовность к зиме:</strong> {formData.isReadyForWinter ? 'ДА' : 'НЕТ'}</p>
</div>
{formData.signatureUrl && (
<div className="mt-8">
<p className="text-sm font-bold mb-2">Подпись представителя собственников:</p>
<img src={formData.signatureUrl} alt="Signature" className="h-20 border-b border-black" />
</div>
)}
</div>
</div>
{/* Header */}
<div className="bg-slate-800 text-white p-4 sticky top-0 z-20 flex justify-between items-center">
<button onClick={onBack} className="text-slate-300 hover:text-white flex items-center gap-1">
<ArrowLeft className="w-5 h-5"/> Назад
</button>
<div className="text-center">
<h2 className="font-bold text-lg">Акт осмотра {formData.number}</h2>
<p className="text-xs text-slate-400">{formData.date}</p>
</div>
<div className="flex gap-2">
{/* Кнопка подключения - только на мобильных при отсутствии подключения */}
{isMobile && connectionStatus === 'disconnected' && (
<button
onClick={() => connectionService.forceCheck()}
className="bg-red-500/20 hover:bg-red-500/30 text-white px-3 py-1.5 rounded-lg text-sm font-bold flex items-center gap-1 border border-red-400/50"
title="Повторить подключение"
>
<WifiOff className="w-4 h-4"/> <span className="hidden sm:inline">Подключить</span>
</button>
)}
<button
onClick={handleGeneratePDF}
disabled={isGeneratingPdf}
className="bg-white/10 hover:bg-white/20 text-white px-3 py-1.5 rounded-lg text-sm font-bold flex items-center gap-1"
title="Скачать PDF"
>
{isGeneratingPdf ? <div className="w-4 h-4 border-2 border-white/50 border-t-white rounded-full animate-spin"/> : <FileText className="w-4 h-4"/>}
<span className="hidden sm:inline">PDF</span>
</button>
<button onClick={() => onSave(formData)} className="bg-emerald-500 hover:bg-emerald-600 text-white px-3 py-1.5 rounded-lg text-sm font-bold flex items-center gap-1">
<Save className="w-4 h-4"/> <span className="hidden sm:inline">Сохранить</span>
</button>
</div>
</div>
{/* Navigation Sections */}
<div className="flex overflow-x-auto border-b border-slate-100 p-1">
{['meta', 'dynamic', 'structure', 'engineering', 'media', 'summary'].map(sec => (
<button
key={sec}
onClick={() => setActiveSection(sec as any)}
className={`flex-1 min-w-[80px] py-2 text-xs font-bold uppercase tracking-wider border-b-2 transition-colors ${activeSection === sec ? 'border-primary-500 text-primary-600' : 'border-transparent text-slate-400'}`}
>
{sec === 'meta' ? 'Инфо' : sec === 'dynamic' ? 'Осмотр' : sec === 'structure' ? 'Констр.' : sec === 'engineering' ? 'Инж.' : sec === 'media' ? 'Фото' : 'Итог'}
</button>
))}
</div>
<div className="p-4 min-h-[400px]">
{/* 1. METADATA */}
{activeSection === 'meta' && (
<div className="space-y-4 animate-fade-in">
<div>
<label className="block text-xs font-bold text-slate-500 mb-1">Тип осмотра</label>
<select
value={formData.type}
onChange={e => setFormData({...formData, type: e.target.value as any})}
className="w-full p-3 bg-slate-50 rounded-xl border border-slate-200 outline-none focus:border-primary-500 text-slate-900"
>
<option value="scheduled_spring">Плановый весенний</option>
<option value="scheduled_autumn">Плановый осенний</option>
<option value="emergency">Внеочередной (Авария)</option>
<option value="acceptance">Приемка дома</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 mb-1">Основание</label>
<input
value={formData.basis || ''}
onChange={e => setFormData({...formData, basis: e.target.value})}
placeholder="Напр: Приказ №12 от 01.01.24"
className="w-full p-3 bg-slate-50 rounded-xl border border-slate-200 outline-none focus:border-primary-500 text-slate-900"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 mb-1">Состав комиссии</label>
<textarea
value={formData.commissionMembers || ''}
onChange={e => setFormData({...formData, commissionMembers: e.target.value})}
placeholder="ФИО сотрудников..."
className="w-full p-3 bg-slate-50 rounded-xl border border-slate-200 outline-none focus:border-primary-500 h-20 text-slate-900"
/>
</div>
<div className="pt-2">
<label className="block text-xs font-bold text-slate-500 mb-2">Геолокация (GPS Подтверждение)</label>
<button
onClick={handleGetGeo}
disabled={!!formData.geolocation || isGeoLoading}
className={`w-full p-3 rounded-xl border-2 border-dashed flex items-center justify-center gap-2 transition-all ${formData.geolocation ? 'border-emerald-500 bg-emerald-50 text-emerald-600' : 'border-slate-300 text-slate-500 hover:border-primary-400 hover:text-primary-600'}`}
>
{isGeoLoading ? (
<span className="animate-pulse">Определяем координаты...</span>
) : formData.geolocation ? (
<>
<MapPin className="w-5 h-5" />
Присутствие подтверждено ({formData.geolocation.lat.toFixed(4)}, {formData.geolocation.lng.toFixed(4)})
</>
) : (
<>
<MapPin className="w-5 h-5" />
Зафиксировать местоположение
</>
)}
</button>
</div>
</div>
)}
{/* 2. DYNAMIC SECTIONS (NEW FORMAT) */}
{activeSection === 'dynamic' && formData.sections && formData.commonSections && (
<div className="space-y-4 animate-fade-in">
<DynamicInspectionSections
sections={formData.sections}
commonSections={formData.commonSections}
building={building}
onUpdateSections={(updated) => setFormData({ ...formData, sections: updated })}
onUpdateCommonSections={(updated) => setFormData({ ...formData, commonSections: updated })}
onSaveToBuilding={(entranceData, floorData) => {
let entrances = building.entrances ?? [];
if (entranceData) {
entrances = entrances.map(e =>
e.number === entranceData.entranceNumber
? {
...e,
technicalCharacteristics: {
...e.technicalCharacteristics,
...entranceData.technicalCharacteristics
}
}
: e
);
}
if (floorData) {
entrances = entrances.map(e =>
e.number === floorData.entranceNumber
? {
...e,
floorElements: {
...e.floorElements,
[floorData.floorNumber]: floorData.elements
}
}
: e
);
}
const updatedBuilding = { ...building, entrances, isDirty: true };
setBuilding(updatedBuilding);
if (updatedBuilding.id) {
storageService.saveBuildingData(updatedBuilding);
backendApi.updateBuilding(updatedBuilding).catch(err => {
console.error('[InspectionsView] Failed to sync building data:', err);
});
}
}}
/>
</div>
)}
{/* 3 & 4. CHECKLISTS (OLD FORMAT) */}
{(activeSection === 'structure' || activeSection === 'engineering') && (
<div className="space-y-4 animate-fade-in">
{(formData.checklist || [])
.filter(item => activeSection === 'structure' ? item.category === 'structural' : item.category === 'engineering')
.map(item => (
<div key={item.id} className="bg-slate-50 p-4 rounded-xl border border-slate-200 shadow-sm transition-all">
<div className="flex justify-between items-center mb-3">
<div className="flex flex-col">
<h4 className="font-bold text-sm text-slate-800 flex items-center gap-2">
{item.category === 'engineering' ? <Wrench className="w-4 h-4 text-slate-400"/> : <Layers className="w-4 h-4 text-slate-400"/>}
{item.label}
</h4>
{item.photos && item.photos.length > 0 && (
<span className="text-[10px] text-emerald-600 font-bold flex items-center gap-0.5 mt-0.5">
<Paperclip className="w-3 h-3"/> {item.photos.length} фото-док.
</span>
)}
</div>
<div className="flex bg-white rounded-lg border border-slate-200 overflow-hidden shadow-sm h-10">
<button
onClick={() => handleUpdateItem(item.id, { status: 'ok' })}
className={`px-4 transition-colors flex items-center justify-center ${item.status === 'ok' ? 'bg-emerald-500 text-white' : 'text-slate-400 hover:bg-slate-50'}`}
title="Все хорошо"
>
<CheckCircle2 className="w-5 h-5"/>
</button>
<div className="w-px bg-slate-200"/>
<button
onClick={() => handleUpdateItem(item.id, { status: 'repair_needed' })}
className={`px-4 transition-colors flex items-center justify-center ${item.status === 'repair_needed' ? 'bg-red-500 text-white' : 'text-slate-400 hover:bg-slate-50'}`}
title="Есть замечания"
>
<AlertTriangle className="w-5 h-5"/>
</button>
</div>
</div>
<div className="mt-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-tight">Фотофиксация состояния:</span>
<button
onClick={() => handleAddItemPhoto(item.id)}
className="flex items-center gap-1.5 px-3 py-1 bg-white border border-slate-200 rounded-lg text-[10px] font-bold text-slate-600 hover:text-primary-600 hover:border-primary-200 transition-colors shadow-sm"
>
<Camera className="w-3.5 h-3.5 text-primary-500"/> Сделать снимок
</button>
</div>
{item.photos && item.photos.length > 0 && (
<div className="flex gap-2 overflow-x-auto no-scrollbar py-1">
{item.photos.map((photo, pIdx) => (
<div key={pIdx} className="w-16 h-16 rounded-lg relative flex-shrink-0 group overflow-hidden border border-slate-200 bg-slate-200">
<img src={photo} alt="Item evidence" className="w-full h-full object-cover" />
<button
onClick={() => handleRemoveItemPhoto(item.id, pIdx)}
className="absolute top-0.5 right-0.5 p-1 bg-red-500/80 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-2.5 h-2.5"/>
</button>
</div>
))}
</div>
)}
</div>
{item.status === 'repair_needed' && (
<div className="space-y-3 mt-3 pt-3 border-t border-slate-200 border-dashed animate-slide-up">
<textarea
value={item.description || ''}
onChange={(e) => handleUpdateItem(item.id, { description: e.target.value })}
placeholder="Опишите выявленный дефект..."
className="w-full p-2.5 text-xs rounded-xl border border-red-100 focus:ring-2 focus:ring-red-100 outline-none text-slate-900 bg-white"
/>
<div className="flex items-center gap-3">
<span className="text-[10px] font-bold text-slate-500 whitespace-nowrap">Износ:</span>
<input
type="range" min="0" max="100" step="5"
value={item.wearPercent || 0}
onChange={(e) => handleUpdateItem(item.id, { wearPercent: Number(e.target.value) })}
className="flex-1 accent-red-500 h-1.5 bg-slate-200 rounded-lg cursor-pointer"
/>
<span className="text-xs font-bold text-red-600 w-8 text-right">{item.wearPercent || 0}%</span>
</div>
<div className="text-[9px] text-slate-400 flex items-center gap-1 italic">
<Info className="w-3 h-3 text-red-400"/>
Будет автоматически создана задача на текущий ремонт
</div>
</div>
)}
</div>
))}
</div>
)}
{/* 4. MEDIA */}
{activeSection === 'media' && (
<div className="space-y-4 animate-fade-in">
<div className="border-2 border-dashed border-slate-300 rounded-xl p-8 flex flex-col items-center justify-center text-slate-400 hover:border-primary-400 transition-colors cursor-pointer bg-slate-50">
<Camera className="w-10 h-10 mb-2" />
<span className="text-sm font-bold text-slate-600">Загрузить общие виды</span>
<span className="text-[10px] opacity-70">Фасады, двор, паспортные таблички</span>
</div>
<div className="space-y-4">
<h4 className="text-xs font-bold text-slate-500 uppercase tracking-wider px-1">Все снимки осмотра</h4>
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{(formData.checklist || [])
.filter(i => i.photos && i.photos.length > 0)
.flatMap((i, itemIdx) => i.photos!.map((p, pIdx) => (
<div key={`${itemIdx}-${pIdx}`} className="aspect-square rounded-xl bg-white border border-slate-200 relative overflow-hidden shadow-sm group">
<img src={p} className="w-full h-full object-cover transition-transform group-hover:scale-110" alt="Evidence" />
<div className="absolute inset-x-0 bottom-0 bg-black/60 p-1.5 translate-y-full group-hover:translate-y-0 transition-transform">
<p className="text-[8px] text-white font-bold leading-tight truncate">{i.label}</p>
</div>
<button className="absolute top-1 right-1 p-1 bg-white/90 rounded-full text-red-500 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm">
<X className="w-3 h-3"/>
</button>
</div>
)))
}
{!(formData.checklist || []).some(i => i.photos && i.photos.length > 0) && (
<div className="col-span-full py-12 flex flex-col items-center justify-center text-slate-300">
<ImageIcon className="w-12 h-12 mb-2 opacity-20"/>
<p className="text-xs italic">Нет прикрепленных фотографий</p>
</div>
)}
</div>
</div>
</div>
)}
{/* 5. SUMMARY */}
{activeSection === 'summary' && (
<div className="space-y-6 animate-fade-in">
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200">
<h3 className="font-bold text-slate-700 mb-3 text-sm uppercase">Результаты обхода</h3>
<div className="space-y-2.5 text-sm">
<div className="flex justify-between">
<span className="text-slate-500">Зафиксировано дефектов:</span>
<span className={`font-black ${issuesCount > 0 ? 'text-red-500' : 'text-emerald-500'}`}>{issuesCount}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-500">Рекомендуемое решение:</span>
<select
value={formData.workCategory || 'maintenance'}
onChange={e => setFormData({...formData, workCategory: e.target.value as any})}
className="bg-white border border-slate-300 rounded-lg px-2 py-1 text-xs font-bold outline-none text-slate-800"
>
<option value="maintenance">Текущий ремонт</option>
<option value="capital">Капитальный</option>
<option value="emergency">Аварийный</option>
</select>
</div>
</div>
</div>
<div className="flex items-center gap-3 p-4 bg-blue-50 rounded-xl border border-blue-100">
<input
type="checkbox"
id="winterReady"
checked={formData.isReadyForWinter}
onChange={e => setFormData({...formData, isReadyForWinter: e.target.checked})}
className="w-5 h-5 accent-blue-600"
/>
<label htmlFor="winterReady" className="text-xs font-bold text-blue-800 cursor-pointer select-none">
Системы дома готовы к эксплуатации в зимний период
</label>
</div>
{/* Signature */}
<div className="space-y-3">
<h3 className="font-bold text-slate-700 text-xs uppercase tracking-wider px-1">Подпись представителя дома / УК</h3>
<div className="relative border-2 border-dashed border-slate-200 rounded-2xl bg-white overflow-hidden shadow-inner">
<canvas
ref={canvasRef}
width={500}
height={180}
className="w-full h-40 touch-none cursor-crosshair"
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
onTouchStart={startDrawing}
onTouchMove={draw}
onTouchEnd={stopDrawing}
/>
{!isDrawing && !formData.signatureUrl && (
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none text-slate-300">
<PenTool className="w-8 h-8 mb-2 opacity-30" />
<span className="text-[10px] font-bold uppercase">Подпишите здесь</span>
</div>
)}
<div className="absolute top-2 right-2 flex gap-2">
<button
onClick={clearSignature}
className="p-1.5 bg-slate-100 hover:bg-red-50 text-slate-500 hover:text-red-500 rounded-lg transition-colors border border-transparent hover:border-red-100 shadow-sm"
title="Очистить"
>
<Eraser className="w-4 h-4"/>
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export const InspectionsView: React.FC<{ building: Building, setBuilding: React.Dispatch<React.SetStateAction<Building>>, isEditing: boolean }> = ({ building, setBuilding, isEditing }) => {
const [activeInspectionId, setActiveInspectionId] = useState<string | null>(null);
// Функция для генерации динамических секций на основе паспорта
const generateInspectionSections = (building: Building): { sections: InspectionSection[], commonSections: InspectionCommonSection[] } => {
const sections: InspectionSection[] = [];
const commonSections: InspectionCommonSection[] = [];
// Генерируем секции для подъездов
const entrancesCount = building.passport?.general?.entrancesCount || building.entrances?.length || 0;
if (entrancesCount > 0) {
for (let i = 1; i <= entrancesCount; i++) {
const entrance = building.entrances?.find(e => e.number === i);
const entranceElements = entrance?.sections?.flatMap(s => s.elements.map(el => ({
id: el.id,
name: el.name,
generalStatus: el.generalStatus || 'NOT_SELECTED',
electroStatus: el.electroStatus || 'NOT_SELECTED',
weldingStatus: el.weldingStatus || 'NOT_SELECTED',
repairType: '',
mainPhoto: undefined,
additionalPhotos: []
}))) || [];
sections.push({
id: `entrance-${i}`,
type: 'entrance',
number: i,
title: `Парадная ${i}`,
elements: entranceElements.length > 0 ? entranceElements : buildEntranceElements(i),
isCollapsed: false
});
}
}
// Генерируем секции для этажей (для каждого подъезда отдельно, с учётом разноэтажности)
const entrancesCountForFloors = building.passport?.general?.entrancesCount || building.entrances?.length || 0;
for (let i = 1; i <= entrancesCountForFloors; i++) {
const entrance = building.entrances?.find(e => e.number === i);
const floorsCount = entrance?.technicalCharacteristics?.floorsCount ?? entrance?.floors ?? building.passport?.general?.floors ?? 0;
if (floorsCount > 0) {
for (let floor = 1; floor <= floorsCount; floor++) {
const savedElements = entrance?.floorElements?.[floor] || [];
const elements = savedElements.length > 0
? savedElements.map(el => ({
...el,
mainPhoto: el.mainPhoto,
additionalPhotos: el.additionalPhotos || []
}))
: buildFloorElements(i, floor);
sections.push({
id: `entrance-${i}-floor-${floor}`,
type: 'floor',
number: floor,
title: `Этаж ${floor} (Подъезд ${i})`,
elements,
isCollapsed: false
});
}
}
}
// Генерируем секции для лифтов
const liftsCount = building.passport?.lifts?.length || (building.passport?.lifts?.[0]?.count || 0);
if (liftsCount > 0) {
for (let i = 1; i <= liftsCount; i++) {
sections.push({
id: `lift-${i}`,
type: 'lift',
number: i,
title: `Лифт ${i}`,
elements: [],
isCollapsed: false
});
}
}
// Общие секции по паспорту (кровля, подвал, техэтаж, чердак, цоколь, фасад, благоустройство, паркинг)
const construction = building.passport?.construction || {};
const baseCommonTitles = ['Кровля', 'Фундамент', 'Фасад', 'Благоустройство', 'Общий паркинг'];
const commonSectionTitles: string[] = [];
baseCommonTitles.forEach(t => commonSectionTitles.push(t));
if (construction.hasBasement) commonSectionTitles.push('Подвал');
if (construction.hasTechFloor) commonSectionTitles.push('Технический этаж');
if (construction.hasAttic) commonSectionTitles.push('Чердак');
if (construction.hasBasementFloor) commonSectionTitles.push('Цоколь');
commonSectionTitles.forEach((title, idx) => {
const id = `common-${idx}-${title.replace(/\s+/g, '-')}`;
const elements = buildCommonSectionElements(title, id);
commonSections.push({
id,
title,
elements,
isCollapsed: true
});
});
return { sections, commonSections };
};
const handleStartInspection = async () => {
const { sections, commonSections } = generateInspectionSections(building);
const newInspection: InspectionAct = {
id: `act-${Date.now()}`,
number: `${Math.floor(Math.random() * 900) + 100}-ОС`,
date: new Date().toISOString().split('T')[0],
inspector: 'Инженер участка',
type: 'scheduled_spring',
status: 'draft',
issuesCount: 0,
checklist: [],
sections,
commonSections
};
const updatedBuilding = {
...building,
inspectionHistory: [newInspection, ...building.inspectionHistory]
};
// Сохраняем в localStorage
storageService.saveBuildingData(updatedBuilding);
// Обновляем состояние
setBuilding(updatedBuilding);
setActiveInspectionId(newInspection.id);
// Пытаемся отправить на сервер (автоматически кэшируется при отсутствии подключения)
try {
await backendApi.updateBuilding(updatedBuilding);
} catch (error) {
console.error('[InspectionsView] Failed to sync new inspection to backend:', error);
// Данные уже сохранены в localStorage и кэше, продолжаем работу
}
};
const updateAct = async (updatedAct: InspectionAct) => {
const updatedBuilding = {
...building,
inspectionHistory: building.inspectionHistory.map(i => i.id === updatedAct.id ? updatedAct : i)
};
// Сохраняем в localStorage
storageService.saveBuildingData(updatedBuilding);
// Обновляем состояние
setBuilding(updatedBuilding);
setActiveInspectionId(null);
// Пытаемся отправить на сервер (автоматически кэшируется при отсутствии подключения)
try {
await backendApi.updateBuilding(updatedBuilding);
} catch (error) {
console.error('[InspectionsView] Failed to sync inspection to backend:', error);
// Данные уже сохранены в localStorage и кэше, продолжаем работу
}
};
const currentInspection = building.inspectionHistory.find(i => i.id === activeInspectionId);
if (activeInspectionId && currentInspection) {
return <InspectionForm act={currentInspection} onSave={updateAct} onBack={() => setActiveInspectionId(null)} building={building} setBuilding={setBuilding} />;
}
return (
<div className="space-y-6 animate-fade-in">
{!isEditing && (
<button onClick={handleStartInspection} className="w-full py-4 bg-primary-600 text-white rounded-xl shadow-lg shadow-primary-500/30 flex items-center justify-center gap-2 font-bold transition-all active:scale-[0.98] hover:bg-primary-700">
<Camera className="w-5 h-5" /> Начать обход с фотофиксацией
</button>
)}
<div>
<h3 className="font-bold text-slate-800 text-sm uppercase mb-3 px-1 tracking-tight flex items-center gap-2">
<History className="w-4 h-4 text-slate-400"/> История осмотров дома
</h3>
<div className="bg-white rounded-xl border border-slate-200 shadow-sm divide-y divide-slate-100 overflow-hidden">
{building.inspectionHistory.length === 0 && (
<div className="p-12 text-center text-slate-400 text-sm italic">История пуста. Запустите первый осмотр.</div>
)}
{building.inspectionHistory.map(act => (
<div
key={act.id}
onClick={() => setActiveInspectionId(act.id)}
className="p-4 flex flex-col md:flex-row md:items-center justify-between hover:bg-slate-50 relative group cursor-pointer transition-colors"
>
<div className="mb-2 md:mb-0">
<div className="flex items-center gap-2">
<span className="font-bold text-slate-700 text-sm">Акт {act.number}</span>
<span className={`text-[9px] px-2 py-0.5 rounded font-bold uppercase ${act.status === 'completed' ? 'bg-emerald-100 text-emerald-600' : 'bg-amber-100 text-amber-600'}`}>
{act.status === 'completed' ? 'Завершен' : 'Черновик'}
</span>
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-slate-500">
<Calendar className="w-3 h-3"/> {act.date}
</div>
</div>
<div className="text-right flex items-center justify-end gap-4">
{act.issuesCount > 0 ? (
<span className="text-xs font-bold text-red-500 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> {act.issuesCount} замечаний
</span>
) : (
<span className="text-xs font-bold text-emerald-500 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Состояние в норме
</span>
)}
<ChevronRight className="w-4 h-4 text-slate-300 group-hover:text-primary-500 transition-colors" />
</div>
</div>
))}
</div>
</div>
</div>
);
};