Initial commit MKD fixes
This commit is contained in:
894
components/building/InspectionsView.tsx
Executable file
894
components/building/InspectionsView.tsx
Executable file
@@ -0,0 +1,894 @@
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user