Files
mkd/components/building/InspectionsView.tsx

895 lines
50 KiB
TypeScript
Raw Normal View History

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