Initial commit MKD fixes

This commit is contained in:
Arsen
2026-02-04 00:17:04 +05:00
commit de94ad707b
312 changed files with 138754 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
import { AccountsView as Accounts } from './AccountsView';
export { Accounts };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,161 @@
import React, { useState } from 'react';
import { X, Plus } from 'lucide-react';
interface Props {
isOpen: boolean;
onClose: () => void;
onConfirm: (fieldName: string, fieldType: 'text' | 'number' | 'checkbox', forAllBuildings: boolean) => void;
section?: 'general' | 'construction' | 'engineering' | 'land' | 'management';
}
export const AddCustomFieldModal: React.FC<Props> = ({ isOpen, onClose, onConfirm, section }) => {
const [fieldName, setFieldName] = useState('');
const [fieldType, setFieldType] = useState<'text' | 'number' | 'checkbox'>('text');
const [forAllBuildings, setForAllBuildings] = useState(false);
if (!isOpen) return null;
const handleConfirm = () => {
if (!fieldName.trim()) {
alert('Введите название поля');
return;
}
onConfirm(fieldName.trim(), fieldType, forAllBuildings);
setFieldName('');
setFieldType('text');
setForAllBuildings(false);
onClose();
};
const handleClose = () => {
setFieldName('');
setFieldType('text');
setForAllBuildings(false);
onClose();
};
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/80 backdrop-blur-md animate-fade-in"
onClick={handleClose}
>
<div
className="bg-white rounded-2xl w-full max-w-md shadow-2xl animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="p-6 border-b border-slate-200">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary-50 rounded-lg">
<Plus className="w-6 h-6 text-primary-500" />
</div>
<div>
<h3 className="text-lg font-black text-slate-900">Добавить новое поле</h3>
<p className="text-sm text-slate-500 mt-1">Укажите название и тип поля</p>
</div>
</div>
<button
onClick={handleClose}
className="p-2 hover:bg-slate-100 rounded-full transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
</div>
{/* Content */}
<div className="p-6 space-y-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-2">
Название поля
</label>
<input
type="text"
value={fieldName}
onChange={(e) => setFieldName(e.target.value)}
placeholder="Например: Материал кровли"
className="w-full p-3 bg-white border border-slate-300 rounded-xl text-sm font-medium text-slate-800 outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleConfirm();
}
}}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-2">
Тип поля
</label>
<div className="grid grid-cols-3 gap-2">
<button
onClick={() => setFieldType('text')}
className={`p-3 rounded-xl border-2 transition-all font-bold text-sm ${
fieldType === 'text'
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300'
}`}
>
Текст
</button>
<button
onClick={() => setFieldType('number')}
className={`p-3 rounded-xl border-2 transition-all font-bold text-sm ${
fieldType === 'number'
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300'
}`}
>
Число
</button>
<button
onClick={() => setFieldType('checkbox')}
className={`p-3 rounded-xl border-2 transition-all font-bold text-sm ${
fieldType === 'checkbox'
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300'
}`}
>
Галочка
</button>
</div>
</div>
<div className="pt-4 border-t border-slate-200">
<label className="flex items-center gap-3 cursor-pointer group">
<input
type="checkbox"
checked={forAllBuildings}
onChange={(e) => setForAllBuildings(e.target.checked)}
className="w-5 h-5 accent-primary-600 cursor-pointer"
/>
<div>
<span className="text-sm font-bold text-slate-800 block">Для всех домов</span>
<span className="text-xs text-slate-500">Поле будет отображаться в паспортах всех домов</span>
</div>
</label>
</div>
</div>
{/* Footer */}
<div className="p-6 border-t border-slate-200 flex gap-3">
<button
onClick={handleClose}
className="flex-1 px-4 py-3 rounded-xl border border-slate-200 text-slate-700 font-bold hover:bg-slate-50 transition-colors"
>
Отмена
</button>
<button
onClick={handleConfirm}
className="flex-1 px-4 py-3 rounded-xl bg-primary-600 text-white font-bold hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
>
<Plus className="w-4 h-4" />
Создать поле
</button>
</div>
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,795 @@
import React, { useState } from 'react';
import { InspectionSection, InspectionCommonSection, InspectionMKDElement, Building } from '../../types';
import {
FLOOR_ELEMENT_NAMES,
ENTRANCE_ELEMENT_NAMES,
COMMON_SECTION_ELEMENTS,
buildElementsFromNames
} from '../../inspectionElementTemplates';
import {
ChevronDown,
ChevronUp,
Plus,
X,
Trash2,
Camera,
Image as ImageIcon,
Gauge,
DoorClosed,
Building2,
Save,
Layers,
Paperclip,
ListPlus
} from 'lucide-react';
interface DynamicInspectionSectionsProps {
sections: InspectionSection[];
commonSections: InspectionCommonSection[];
building: Building;
onUpdateSections: (sections: InspectionSection[]) => void;
onUpdateCommonSections: (commonSections: InspectionCommonSection[]) => void;
onSaveToBuilding?: (entranceData: any, floorData: any) => void; // Сохранение данных в building
}
export const DynamicInspectionSections: React.FC<DynamicInspectionSectionsProps> = ({
sections,
commonSections,
building,
onUpdateSections,
onUpdateCommonSections,
onSaveToBuilding
}) => {
const [activeTab, setActiveTab] = useState<string | null>(sections.length > 0 ? sections[0].id : null);
const [editingElement, setEditingElement] = useState<{ sectionId: string; elementId: string | null } | null>(null);
const [editingEntranceTech, setEditingEntranceTech] = useState<number | null>(null); // Редактирование тех. характеристик подъезда
// Группируем секции по типу
const entranceSections = sections.filter(s => s.type === 'entrance');
const floorSections = sections.filter(s => s.type === 'floor');
const liftSections = sections.filter(s => s.type === 'lift');
const handleAddElement = (sectionId: string, isCommon: boolean = false) => {
const newElement: InspectionMKDElement = {
id: `element-${Date.now()}`,
name: '',
generalStatus: 'NOT_SELECTED',
electroStatus: 'NOT_SELECTED',
weldingStatus: 'NOT_SELECTED',
repairType: '',
mainPhoto: undefined,
additionalPhotos: []
};
if (isCommon) {
const updated = commonSections.map(s =>
s.id === sectionId
? { ...s, elements: [...s.elements, newElement] }
: s
);
onUpdateCommonSections(updated);
} else {
const updated = sections.map(s =>
s.id === sectionId
? { ...s, elements: [...s.elements, newElement] }
: s
);
onUpdateSections(updated);
// Сохраняем элементы этажа в building, если это секция этажа
const section = sections.find(s => s.id === sectionId);
if (section && section.type === 'floor') {
const match = sectionId.match(/entrance-(\d+)-floor-(\d+)/);
if (match && onSaveToBuilding) {
const entranceNum = parseInt(match[1]);
const floorNum = parseInt(match[2]);
const updatedSection = updated.find(s => s.id === sectionId);
if (updatedSection) {
onSaveToBuilding(null, {
entranceNumber: entranceNum,
floorNumber: floorNum,
elements: updatedSection.elements
});
}
}
}
}
setEditingElement({ sectionId, elementId: newElement.id });
};
const handleAddFromTemplate = (sectionId: string, isCommon: boolean = false) => {
const existingNames = new Set(
isCommon
? (commonSections.find(s => s.id === sectionId)?.elements || []).map(e => e.name)
: (sections.find(s => s.id === sectionId)?.elements || []).map(e => e.name)
);
let namesToAdd: string[] = [];
let prefix = sectionId;
if (isCommon) {
const section = commonSections.find(s => s.id === sectionId);
if (section) {
const templateNames = COMMON_SECTION_ELEMENTS[section.title];
if (templateNames) namesToAdd = templateNames.filter(n => !existingNames.has(n));
}
} else {
const section = sections.find(s => s.id === sectionId);
if (section) {
if (section.type === 'floor') {
const match = sectionId.match(/entrance-(\d+)-floor-(\d+)/);
if (match) {
namesToAdd = FLOOR_ELEMENT_NAMES.filter(n => !existingNames.has(n));
prefix = `entrance-${match[1]}-floor-${match[2]}`;
}
} else if (section.type === 'entrance') {
namesToAdd = ENTRANCE_ELEMENT_NAMES.filter(n => !existingNames.has(n));
prefix = sectionId;
}
}
}
if (namesToAdd.length === 0) return;
const newElements = buildElementsFromNames(namesToAdd, prefix);
if (isCommon) {
const updated = commonSections.map(s =>
s.id === sectionId ? { ...s, elements: [...s.elements, ...newElements] } : s
);
onUpdateCommonSections(updated);
} else {
const updated = sections.map(s =>
s.id === sectionId ? { ...s, elements: [...s.elements, ...newElements] } : s
);
onUpdateSections(updated);
const section = sections.find(s => s.id === sectionId);
if (section?.type === 'floor') {
const match = sectionId.match(/entrance-(\d+)-floor-(\d+)/);
if (match && onSaveToBuilding) {
const entranceNum = parseInt(match[1]);
const floorNum = parseInt(match[2]);
const updatedSection = updated.find(s => s.id === sectionId);
if (updatedSection) {
onSaveToBuilding(null, {
entranceNumber: entranceNum,
floorNumber: floorNum,
elements: updatedSection.elements
});
}
}
}
}
};
const handleUpdateElement = (sectionId: string, elementId: string, updates: Partial<InspectionMKDElement>, isCommon: boolean = false) => {
if (isCommon) {
const updated = commonSections.map(s =>
s.id === sectionId
? {
...s,
elements: s.elements.map(el =>
el.id === elementId ? { ...el, ...updates } : el
)
}
: s
);
onUpdateCommonSections(updated);
} else {
const updated = sections.map(s =>
s.id === sectionId
? {
...s,
elements: s.elements.map(el =>
el.id === elementId ? { ...el, ...updates } : el
)
}
: s
);
onUpdateSections(updated);
// Сохраняем элементы этажа в building, если это секция этажа
const section = sections.find(s => s.id === sectionId);
if (section && section.type === 'floor') {
// Извлекаем номер подъезда и этажа из sectionId (формат: entrance-{num}-floor-{floor})
const match = sectionId.match(/entrance-(\d+)-floor-(\d+)/);
if (match && onSaveToBuilding) {
const entranceNum = parseInt(match[1]);
const floorNum = parseInt(match[2]);
const updatedSection = updated.find(s => s.id === sectionId);
if (updatedSection) {
onSaveToBuilding(null, {
entranceNumber: entranceNum,
floorNumber: floorNum,
elements: updatedSection.elements
});
}
}
}
}
};
const handleDeleteElement = (sectionId: string, elementId: string, isCommon: boolean = false) => {
if (isCommon) {
const updated = commonSections.map(s =>
s.id === sectionId
? { ...s, elements: s.elements.filter(el => el.id !== elementId) }
: s
);
onUpdateCommonSections(updated);
} else {
const updated = sections.map(s =>
s.id === sectionId
? { ...s, elements: s.elements.filter(el => el.id !== elementId) }
: s
);
onUpdateSections(updated);
// Сохраняем элементы этажа в building, если это секция этажа
const section = sections.find(s => s.id === sectionId);
if (section && section.type === 'floor') {
const match = sectionId.match(/entrance-(\d+)-floor-(\d+)/);
if (match && onSaveToBuilding) {
const entranceNum = parseInt(match[1]);
const floorNum = parseInt(match[2]);
const updatedSection = updated.find(s => s.id === sectionId);
if (updatedSection) {
onSaveToBuilding(null, {
entranceNumber: entranceNum,
floorNumber: floorNum,
elements: updatedSection.elements
});
}
}
}
}
};
const handleToggleSection = (sectionId: string, isCommon: boolean = false) => {
if (isCommon) {
const updated = commonSections.map(s =>
s.id === sectionId ? { ...s, isCollapsed: !s.isCollapsed } : s
);
onUpdateCommonSections(updated);
} else {
const updated = sections.map(s =>
s.id === sectionId ? { ...s, isCollapsed: !s.isCollapsed } : s
);
onUpdateSections(updated);
}
};
const renderElement = (element: InspectionMKDElement, sectionId: string, isCommon: boolean = false) => {
const isEditing = editingElement?.sectionId === sectionId && editingElement?.elementId === element.id;
return (
<div key={element.id} className="bg-slate-50 p-4 rounded-xl border border-slate-200 shadow-sm transition-all mb-3">
<div className="flex justify-between items-start mb-3">
{isEditing ? (
<input
type="text"
value={element.name}
onChange={(e) => handleUpdateElement(sectionId, element.id, { name: e.target.value }, isCommon)}
placeholder="Название элемента МКД"
className="flex-1 px-3 py-2 bg-white border border-slate-200 rounded-xl text-sm font-bold focus:ring-2 focus:ring-primary-500 outline-none"
autoFocus
/>
) : (
<div className="flex items-center gap-2 flex-1">
<h4 className="font-bold text-sm text-slate-800 flex items-center gap-2">
<Layers className="w-4 h-4 text-slate-400"/>
{element.name || 'Новый элемент'}
</h4>
<button
onClick={() => setEditingElement({ sectionId, elementId: element.id })}
className="text-xs text-primary-600 hover:text-primary-700 font-bold px-2 py-1 hover:bg-primary-50 rounded-lg transition-colors"
>
+ Изменить
</button>
</div>
)}
<button
onClick={() => handleDeleteElement(sectionId, element.id, isCommon)}
className="text-red-500 hover:text-red-700 p-1 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-3 gap-3 mb-3">
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-1">Общий строй</label>
<select
value={element.generalStatus}
onChange={(e) => handleUpdateElement(sectionId, element.id, { generalStatus: e.target.value as any }, isCommon)}
className="w-full px-3 py-2 bg-white border border-slate-200 rounded-xl text-xs focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="NOT_SELECTED">Выбрать</option>
<option value="OK">Удовлет</option>
<option value="WARNING">Неуд</option>
<option value="CRITICAL">Критично</option>
</select>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-1">Электроснабжение</label>
<select
value={element.electroStatus}
onChange={(e) => handleUpdateElement(sectionId, element.id, { electroStatus: e.target.value as any }, isCommon)}
className="w-full px-3 py-2 bg-white border border-slate-200 rounded-xl text-xs focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="NOT_SELECTED">Выбрать</option>
<option value="OK">Удовлет</option>
<option value="WARNING">Неуд</option>
<option value="CRITICAL">Критично</option>
</select>
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-1">Слесарные, сварочные</label>
<select
value={element.weldingStatus}
onChange={(e) => handleUpdateElement(sectionId, element.id, { weldingStatus: e.target.value as any }, isCommon)}
className="w-full px-3 py-2 bg-white border border-slate-200 rounded-xl text-xs focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="NOT_SELECTED">Выбрать</option>
<option value="OK">Удовлет</option>
<option value="WARNING">Неуд</option>
<option value="CRITICAL">Критично</option>
</select>
</div>
</div>
<div className="mb-3">
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-1">Вид требуемого ремонта</label>
<textarea
value={element.repairType || ''}
onChange={(e) => handleUpdateElement(sectionId, element.id, { repairType: e.target.value }, isCommon)}
placeholder="Опишите требуемый ремонт..."
className="w-full px-3 py-2 bg-white border border-slate-200 rounded-xl text-xs min-h-[60px] focus:ring-2 focus:ring-primary-500 outline-none"
/>
</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>
<div className="flex gap-2">
<button 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>
<button 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>
</div>
{(element.mainPhoto || (element.additionalPhotos && element.additionalPhotos.length > 0)) && (
<div className="flex gap-2 overflow-x-auto no-scrollbar py-1">
{element.mainPhoto && (
<div className="w-16 h-16 rounded-lg relative flex-shrink-0 group overflow-hidden border border-slate-200 bg-slate-200">
<img src={element.mainPhoto} alt="Main photo" className="w-full h-full object-cover" />
<button
onClick={() => handleUpdateElement(sectionId, element.id, { mainPhoto: undefined }, isCommon)}
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>
)}
{element.additionalPhotos?.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="Additional photo" className="w-full h-full object-cover" />
<button
onClick={() => {
const updated = element.additionalPhotos?.filter((_, i) => i !== pIdx) || [];
handleUpdateElement(sectionId, element.id, { additionalPhotos: updated }, isCommon);
}}
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>
</div>
);
};
return (
<div className="space-y-4">
{/* Табы для подъездов */}
{entranceSections.length > 0 && (
<div className="mb-6">
<h3 className="font-bold text-slate-800 text-sm mb-3 flex items-center gap-2">
<DoorClosed className="w-4 h-4 text-primary-500" /> Парадная
</h3>
<div className="flex gap-2 overflow-x-auto pb-2">
{entranceSections.map(section => (
<button
key={section.id}
onClick={() => setActiveTab(section.id)}
className={`px-4 py-2 rounded-lg font-bold text-sm whitespace-nowrap transition-all ${
activeTab === section.id
? 'bg-primary-600 text-white shadow-lg'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{section.number}
</button>
))}
</div>
{activeTab && entranceSections.find(s => s.id === activeTab) && (() => {
const currentSection = entranceSections.find(s => s.id === activeTab)!;
const entrance = building.entrances?.find(e => e.number === currentSection.number);
const techChars = entrance?.technicalCharacteristics || {};
return (
<div className="mt-4 space-y-4">
{/* Технические характеристики подъезда */}
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm mb-4">
<div className="flex justify-between items-center mb-4">
<h4 className="font-bold text-slate-800 text-sm">
ТЕХНИЧЕСКИЕ ХАРАКТЕРИСТИКИ ПОДЪЕЗДА {currentSection.number}
</h4>
<button
onClick={() => setEditingEntranceTech(editingEntranceTech === currentSection.number ? null : currentSection.number)}
className="text-primary-600 hover:text-primary-700 text-xs font-bold"
>
{editingEntranceTech === currentSection.number ? 'Сохранить' : 'Редактировать'}
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-1">Наличие подвала</label>
{editingEntranceTech === currentSection.number ? (
<select
value={techChars.hasBasement || 'NOT_SELECTED'}
onChange={(e) => {
if (onSaveToBuilding) {
onSaveToBuilding({
entranceNumber: currentSection.number,
technicalCharacteristics: {
...techChars,
hasBasement: e.target.value as any
}
}, null);
}
}}
className="w-full px-2 py-1.5 border border-slate-300 rounded-lg text-xs"
>
<option value="NOT_SELECTED">Выбрать</option>
<option value="YES">Да</option>
<option value="NO">Нет</option>
</select>
) : (
<p className="text-sm font-bold text-slate-800">{techChars.hasBasement === 'YES' ? 'Да' : techChars.hasBasement === 'NO' ? 'Нет' : '-'}</p>
)}
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-1">Наличие тех. этажа</label>
{editingEntranceTech === currentSection.number ? (
<select
value={techChars.hasTechFloor || 'NOT_SELECTED'}
onChange={(e) => {
if (onSaveToBuilding) {
onSaveToBuilding({
entranceNumber: currentSection.number,
technicalCharacteristics: {
...techChars,
hasTechFloor: e.target.value as any
}
}, null);
}
}}
className="w-full px-2 py-1.5 border border-slate-300 rounded-lg text-xs"
>
<option value="NOT_SELECTED">Выбрать</option>
<option value="YES">Да</option>
<option value="NO">Нет</option>
</select>
) : (
<p className="text-sm font-bold text-slate-800">{techChars.hasTechFloor === 'YES' ? 'Да' : techChars.hasTechFloor === 'NO' ? 'Нет' : '-'}</p>
)}
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-1">Наличие паркинга</label>
{editingEntranceTech === currentSection.number ? (
<select
value={techChars.hasParking || 'NOT_SELECTED'}
onChange={(e) => {
if (onSaveToBuilding) {
onSaveToBuilding({
entranceNumber: currentSection.number,
technicalCharacteristics: {
...techChars,
hasParking: e.target.value as any
}
}, null);
}
}}
className="w-full px-2 py-1.5 border border-slate-300 rounded-lg text-xs"
>
<option value="NOT_SELECTED">Выбрать</option>
<option value="YES">Да</option>
<option value="NO">Нет</option>
</select>
) : (
<p className="text-sm font-bold text-slate-800">{techChars.hasParking === 'YES' ? 'Да' : techChars.hasParking === 'NO' ? 'Нет' : '-'}</p>
)}
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-1">Наличие чердака</label>
{editingEntranceTech === currentSection.number ? (
<select
value={techChars.hasAttic || 'NOT_SELECTED'}
onChange={(e) => {
if (onSaveToBuilding) {
onSaveToBuilding({
entranceNumber: currentSection.number,
technicalCharacteristics: {
...techChars,
hasAttic: e.target.value as any
}
}, null);
}
}}
className="w-full px-2 py-1.5 border border-slate-300 rounded-lg text-xs"
>
<option value="NOT_SELECTED">Выбрать</option>
<option value="YES">Да</option>
<option value="NO">Нет</option>
</select>
) : (
<p className="text-sm font-bold text-slate-800">{techChars.hasAttic === 'YES' ? 'Да' : techChars.hasAttic === 'NO' ? 'Нет' : '-'}</p>
)}
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-1">Количество этажей</label>
{editingEntranceTech === currentSection.number ? (
<input
type="number"
value={techChars.floorsCount || entrance?.floors || ''}
onChange={(e) => {
if (onSaveToBuilding) {
onSaveToBuilding({
entranceNumber: currentSection.number,
technicalCharacteristics: {
...techChars,
floorsCount: parseInt(e.target.value) || undefined
}
}, null);
}
}}
className="w-full px-2 py-1.5 border border-slate-300 rounded-lg text-xs"
/>
) : (
<p className="text-sm font-bold text-slate-800">{techChars.floorsCount || entrance?.floors || '-'}</p>
)}
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-1">Количество лифтов</label>
{editingEntranceTech === currentSection.number ? (
<input
type="number"
value={techChars.liftsCount || entrance?.liftsCount || ''}
onChange={(e) => {
if (onSaveToBuilding) {
onSaveToBuilding({
entranceNumber: currentSection.number,
technicalCharacteristics: {
...techChars,
liftsCount: parseInt(e.target.value) || undefined
}
}, null);
}
}}
className="w-full px-2 py-1.5 border border-slate-300 rounded-lg text-xs"
/>
) : (
<p className="text-sm font-bold text-slate-800">{techChars.liftsCount || entrance?.liftsCount || '-'}</p>
)}
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-1">Наличие мансарды</label>
{editingEntranceTech === currentSection.number ? (
<select
value={techChars.hasMansard || 'NOT_SELECTED'}
onChange={(e) => {
if (onSaveToBuilding) {
onSaveToBuilding({
entranceNumber: currentSection.number,
technicalCharacteristics: {
...techChars,
hasMansard: e.target.value as any
}
}, null);
}
}}
className="w-full px-2 py-1.5 border border-slate-300 rounded-lg text-xs"
>
<option value="NOT_SELECTED">Выбрать</option>
<option value="YES">Да</option>
<option value="NO">Нет</option>
</select>
) : (
<p className="text-sm font-bold text-slate-800">{techChars.hasMansard === 'YES' ? 'Да' : techChars.hasMansard === 'NO' ? 'Нет' : '-'}</p>
)}
</div>
<div>
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-1">Кол-во входных групп</label>
{editingEntranceTech === currentSection.number ? (
<input
type="number"
value={techChars.entranceGroupsCount || entrance?.entranceGroupsCount || ''}
onChange={(e) => {
if (onSaveToBuilding) {
onSaveToBuilding({
entranceNumber: currentSection.number,
technicalCharacteristics: {
...techChars,
entranceGroupsCount: parseInt(e.target.value) || undefined
}
}, null);
}
}}
className="w-full px-2 py-1.5 border border-slate-300 rounded-lg text-xs"
/>
) : (
<p className="text-sm font-bold text-slate-800">{techChars.entranceGroupsCount || entrance?.entranceGroupsCount || '-'}</p>
)}
</div>
</div>
</div>
{/* Элементы МКД подъезда */}
{currentSection.elements.map(el => renderElement(el, currentSection.id, false))}
<div className="flex gap-2">
<button
onClick={() => handleAddElement(currentSection.id, false)}
className="flex-1 py-3 bg-primary-600 text-white rounded-xl font-bold text-sm flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 hover:bg-primary-700 transition-all"
>
<Plus className="w-4 h-4" /> ДОБАВИТЬ ЭЛЕМЕНТ МКД
</button>
<button
type="button"
onClick={() => handleAddFromTemplate(currentSection.id, false)}
className="py-3 px-4 bg-white border border-slate-200 text-slate-700 rounded-xl font-bold text-sm flex items-center justify-center gap-2 hover:bg-slate-50 transition-all"
title="Добавить недостающие пункты из типового чек-листа парадной"
>
<ListPlus className="w-4 h-4" /> Из шаблона
</button>
</div>
</div>
);
})()}
</div>
)}
{/* Табы для этажей */}
{floorSections.length > 0 && (
<div className="mb-6">
<h3 className="font-bold text-slate-800 text-sm mb-3 flex items-center gap-2">
<Building2 className="w-4 h-4 text-primary-500" /> Этажи
</h3>
<div className="flex gap-2 overflow-x-auto pb-2 flex-wrap">
{floorSections.map(section => {
const match = section.id.match(/entrance-(\d+)-floor-(\d+)/);
const entranceNum = match ? match[1] : '';
const floorNum = section.number ?? '';
const label = entranceNum ? `П${entranceNum} Этаж ${floorNum}` : `Этаж ${floorNum}`;
return (
<button
key={section.id}
onClick={() => setActiveTab(section.id)}
className={`px-4 py-2 rounded-lg font-bold text-sm whitespace-nowrap transition-all ${
activeTab === section.id
? 'bg-primary-600 text-white shadow-lg'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{label}
</button>
);
})}
</div>
{activeTab && floorSections.find(s => s.id === activeTab) && (() => {
const currentSection = floorSections.find(s => s.id === activeTab)!;
// Извлекаем номер подъезда из sectionId
const match = currentSection.id.match(/entrance-(\d+)-floor-(\d+)/);
const entranceNum = match ? parseInt(match[1]) : null;
const floorNum = currentSection.number;
return (
<div className="mt-4 space-y-4">
<h4 className="font-bold text-slate-800 text-sm mb-2">
ЭЛЕМЕНТЫ МКД ЭТАЖА {floorNum}{entranceNum ? ` (Подъезд ${entranceNum})` : ''}
</h4>
{currentSection.elements.map(el => renderElement(el, currentSection.id, false))}
<div className="flex gap-2">
<button
onClick={() => handleAddElement(currentSection.id, false)}
className="flex-1 py-3 bg-primary-600 text-white rounded-xl font-bold text-sm flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 hover:bg-primary-700 transition-all"
>
<Plus className="w-4 h-4" /> ДОБАВИТЬ ЭЛЕМЕНТ МКД
</button>
<button
type="button"
onClick={() => handleAddFromTemplate(currentSection.id, false)}
className="py-3 px-4 bg-white border border-slate-200 text-slate-700 rounded-xl font-bold text-sm flex items-center justify-center gap-2 hover:bg-slate-50 transition-all"
title="Добавить недостающие пункты из типового чек-листа этажа"
>
<ListPlus className="w-4 h-4" /> Из шаблона
</button>
</div>
</div>
);
})()}
</div>
)}
{/* Секции для лифтов */}
{liftSections.length > 0 && (
<div className="mb-6">
<h3 className="font-bold text-slate-800 text-sm mb-3 flex items-center gap-2">
<Gauge className="w-4 h-4 text-primary-500" /> Лифт
</h3>
{liftSections.map(section => (
<div key={section.id} className="mb-4 bg-slate-50 p-4 rounded-xl border border-slate-200 shadow-sm">
<div className="flex justify-between items-center mb-4">
<h4 className="font-bold text-slate-800 text-lg">Лифт {section.number}</h4>
<button
onClick={() => handleToggleSection(section.id, false)}
className="text-slate-500 hover:text-slate-700 transition-colors"
>
{section.isCollapsed ? <ChevronDown className="w-5 h-5" /> : <ChevronUp className="w-5 h-5" />}
</button>
</div>
{!section.isCollapsed && (
<div className="space-y-4">
{section.elements.map(el => renderElement(el, section.id, false))}
<button
onClick={() => handleAddElement(section.id, false)}
className="w-full py-3 bg-primary-600 text-white rounded-xl font-bold text-sm flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 hover:bg-primary-700 transition-all"
>
<Plus className="w-4 h-4" /> ДОБАВИТЬ ЭЛЕМЕНТ МКД
</button>
</div>
)}
</div>
))}
</div>
)}
{/* Общие секции */}
{commonSections.map(section => (
<div key={section.id} className="bg-slate-50 p-4 rounded-xl border border-slate-200 shadow-sm mb-4">
<div className="flex justify-between items-center mb-4">
<h3 className="font-bold text-slate-800 text-sm">{section.title}</h3>
<button
onClick={() => handleToggleSection(section.id, true)}
className="text-slate-500 hover:text-slate-700 transition-colors"
>
{section.isCollapsed ? <ChevronDown className="w-5 h-5" /> : <ChevronUp className="w-5 h-5" />}
</button>
</div>
{!section.isCollapsed && (
<div className="space-y-4">
{section.elements.map(el => renderElement(el, section.id, true))}
<div className="flex gap-2">
<button
onClick={() => handleAddElement(section.id, true)}
className="flex-1 py-3 bg-primary-600 text-white rounded-xl font-bold text-sm flex items-center justify-center gap-2 shadow-lg shadow-primary-500/20 hover:bg-primary-700 transition-all"
>
<Plus className="w-4 h-4" /> ДОБАВИТЬ ЭЛЕМЕНТ МКД
</button>
<button
type="button"
onClick={() => handleAddFromTemplate(section.id, true)}
className="py-3 px-4 bg-white border border-slate-200 text-slate-700 rounded-xl font-bold text-sm flex items-center justify-center gap-2 hover:bg-slate-50 transition-all"
title="Добавить недостающие пункты из типового чек-листа"
>
<ListPlus className="w-4 h-4" /> Из шаблона
</button>
</div>
</div>
)}
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,78 @@
import React, { useState, useEffect, useRef } from 'react';
import { CheckCircle2, X } from 'lucide-react';
interface EditableFieldProps {
value: string | number | boolean;
onChange: (val: string) => void;
isEditing: boolean;
className?: string;
placeholder?: string;
type?: 'text' | 'number' | 'date' | 'checkbox';
}
export const EditableField: React.FC<EditableFieldProps> = ({ value, onChange, isEditing, className = "", placeholder, type = 'text' }) => {
const [localValue, setLocalValue] = useState(value);
const inputRef = useRef<HTMLInputElement>(null);
const wasFocused = useRef(false);
// Синхронизируем локальное значение с внешним
useEffect(() => {
setLocalValue(value);
}, [value]);
// Восстанавливаем фокус после обновления (setSelectionRange не поддерживается для type="number" и "date")
useEffect(() => {
if (wasFocused.current && inputRef.current && isEditing) {
const input = inputRef.current;
input.focus();
const inputType = (input.type || 'text').toLowerCase();
if (inputType === 'text' || inputType === 'search' || inputType === 'url' || inputType === 'tel' || inputType === 'password') {
const cursorPosition = input.selectionStart ?? 0;
input.setSelectionRange(cursorPosition, cursorPosition);
}
wasFocused.current = false;
}
});
if (type === 'checkbox') {
const boolValue = Boolean(value);
if (!isEditing) return boolValue ? <CheckCircle2 className="w-4 h-4 text-emerald-500" /> : <X className="w-4 h-4 text-slate-300" />;
return (
<input
type="checkbox"
checked={boolValue}
onChange={(e) => onChange(String(e.target.checked))}
className="w-5 h-5 accent-primary-600 cursor-pointer"
/>
);
}
if (!isEditing) {
return <span className={`truncate ${className}`}>{value}</span>;
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
wasFocused.current = true;
onChange(newValue);
};
const handleFocus = () => {
wasFocused.current = true;
};
return (
<input
ref={inputRef}
type={type}
value={localValue as string | number}
onChange={handleChange}
onFocus={handleFocus}
className={`w-full bg-primary-50 border-b-2 border-primary-200 focus:border-primary-500 outline-none px-1 rounded-t transition-colors ${className}`}
placeholder={placeholder}
onClick={(e) => e.stopPropagation()}
/>
);
};

View File

@@ -0,0 +1,3 @@
import { FinanceView as Finance } from './FinanceView';
export { Finance };

View File

@@ -0,0 +1,296 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Building, Invoice, InvoiceStatus, PaymentInvoice, PaymentInvoiceStatus } from '../../types';
import { Download, Plus, Activity, CheckCircle2, X, Trash2, Calendar, FileCheck, AlertCircle, Clock, Wallet } from 'lucide-react';
import { EditableField } from './EditableField';
import { apiClient } from '../../services/apiClient';
import { PaymentInvoiceDetail } from '../finance/PaymentInvoiceDetail';
export const FinanceView: React.FC<{ building: Building, setBuilding: React.Dispatch<React.SetStateAction<Building>>, isEditing: boolean }> = ({ building, setBuilding, isEditing }) => {
const invoices = building.financials.invoices || [];
// Счета на оплату из финансового модуля (payment_invoices)
const [paymentInvoices, setPaymentInvoices] = useState<PaymentInvoice[]>([]);
const [loadingPaymentInvoices, setLoadingPaymentInvoices] = useState(false);
const [selectedPaymentInvoice, setSelectedPaymentInvoice] = useState<PaymentInvoice | null>(null);
const currentUserId = 'user-1'; // TODO: взять из системы авторизации
const paymentStatusConfig: Record<PaymentInvoiceStatus, { label: string; color: string; bg: string }> = {
draft: { label: 'Черновик', color: 'text-slate-500', bg: 'bg-slate-100' },
pending_manager_approval: { label: 'На согл. руков.', color: 'text-amber-600', bg: 'bg-amber-50' },
pending_finance_manager_approval: { label: 'На согл. фин. руков.', color: 'text-blue-600', bg: 'bg-blue-50' },
approved: { label: 'Согласован', color: 'text-indigo-600', bg: 'bg-indigo-50' },
scheduled: { label: 'В графике', color: 'text-purple-600', bg: 'bg-purple-50' },
paid: { label: 'Оплачен', color: 'text-emerald-600', bg: 'bg-emerald-50' },
postponed: { label: 'Отложен', color: 'text-orange-600', bg: 'bg-orange-50' },
cancelled: { label: 'Отменен', color: 'text-red-600', bg: 'bg-red-50' },
rejected: { label: 'Отклонен', color: 'text-red-600', bg: 'bg-red-50' },
completed: { label: 'Выполнено', color: 'text-green-600', bg: 'bg-green-50' },
};
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
setLoadingPaymentInvoices(true);
// Берем только "building" и фильтруем по текущему дому на фронте
const resp = await apiClient.get<{ invoices: PaymentInvoice[] }>(
`/finance/payment-invoices?purposeType=building&limit=200`
);
if (cancelled) return;
const filtered = (resp?.invoices || []).filter((inv) =>
Array.isArray(inv.purposeBuildingIds) && inv.purposeBuildingIds.includes(building.id)
);
setPaymentInvoices(filtered);
} catch (e) {
if (!cancelled) setPaymentInvoices([]);
console.warn('[FinanceView] Failed to load payment invoices:', e);
} finally {
if (!cancelled) setLoadingPaymentInvoices(false);
}
};
load();
return () => {
cancelled = true;
};
}, [building.id]);
const refreshPaymentInvoices = async () => {
try {
setLoadingPaymentInvoices(true);
const resp = await apiClient.get<{ invoices: PaymentInvoice[] }>(
`/finance/payment-invoices?purposeType=building&limit=200`
);
const filtered = (resp?.invoices || []).filter((inv) =>
Array.isArray(inv.purposeBuildingIds) && inv.purposeBuildingIds.includes(building.id)
);
setPaymentInvoices(filtered);
} catch (e) {
console.warn('[FinanceView] Failed to refresh payment invoices:', e);
} finally {
setLoadingPaymentInvoices(false);
}
};
const paymentInvoicesTotals = useMemo(() => {
const total = paymentInvoices.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
const scheduled = paymentInvoices
.filter((i) => i.status === 'scheduled')
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
return { total, scheduled };
}, [paymentInvoices]);
// Calculate Totals
const scheduledTotal = paymentInvoices.length > 0
? paymentInvoicesTotals.scheduled
: invoices.filter(i => i.status === 'scheduled' || i.status === 'overdue').reduce((sum, i) => sum + i.amount, 0);
const balance = building.financials.balance;
const hasBalance = balance > 0;
const hasScheduled = scheduledTotal > 0;
const isDeficit = scheduledTotal > balance;
const handleAddInvoice = () => {
const newInvoice: Invoice = {
id: `inv-${Date.now()}`,
buildingId: building.id,
address: building.passport.address,
contractorName: 'Новый Контрагент',
serviceName: 'Услуга по содержанию',
amount: 0,
date: new Date().toISOString().split('T')[0],
status: 'pending_approval',
priority: 'medium',
closingDocsReceived: false
};
setBuilding(prev => ({
...prev,
financials: { ...prev.financials, invoices: [newInvoice, ...prev.financials.invoices] }
}));
};
/**
* Updates an invoice with a specific field or a partial object of updates
*/
const updateInvoice = (id: string, field: keyof Invoice, value: any, extra?: Partial<Invoice>) => {
setBuilding(prev => ({
...prev,
financials: {
...prev.financials,
invoices: prev.financials.invoices.map(inv => inv.id === id ? { ...inv, [field]: value, ...extra } : inv)
}
}));
};
const deleteInvoice = (id: string) => {
setBuilding(prev => ({
...prev,
financials: {
...prev.financials,
invoices: prev.financials.invoices.filter(i => i.id !== id)
}
}));
};
return (
<div className="space-y-6 animate-fade-in">
{/* 1. Cash Flow Visual */}
<div className={`bg-white p-5 rounded-2xl border shadow-sm relative group ${isEditing ? 'border-primary-200' : 'border-slate-200'}`}>
<div className="flex justify-between items-center mb-4">
<h3 className="font-bold text-slate-800 flex items-center gap-2">
<Activity className="w-5 h-5 text-indigo-500" /> Финансовый резерв
</h3>
<span className={`text-[10px] font-black px-2 py-1 rounded tracking-widest uppercase ${isDeficit ? 'bg-red-500 text-white animate-pulse' : 'bg-emerald-100 text-emerald-600'}`}>
{isDeficit ? 'ДЕФИЦИТ СРЕДСТВ' : 'РЕЗЕРВ OK'}
</span>
</div>
<div className="mb-4 grid grid-cols-2 gap-4">
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100">
<p className="text-[9px] font-bold text-slate-400 uppercase mb-1">Доступно</p>
<div className="flex items-center gap-1">
<EditableField value={balance} onChange={(v) => setBuilding({...building, financials: {...building.financials, balance: Number(v)}})} isEditing={isEditing} type="number" className="text-lg font-black text-slate-900 w-24"/>
<span className="font-bold text-slate-400"></span>
</div>
<p className="text-[10px] text-slate-400 mt-1">
{hasBalance
? `После оплат останется ≈ ${(balance - scheduledTotal).toLocaleString('ru-RU')}`
: 'На счетах нет средств'}
</p>
</div>
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100">
<p className="text-[9px] font-bold text-slate-400 uppercase mb-1">В графике оплат</p>
<p className={`text-lg font-black ${hasScheduled ? 'text-slate-900' : 'text-slate-400'}`}>
{scheduledTotal.toLocaleString()}
</p>
<p className="text-[10px] text-slate-400 mt-1">
{hasScheduled ? 'Платежи в графике есть' : 'Платежей в графике нет'}
</p>
</div>
</div>
<div className="relative h-2 bg-slate-100 rounded-full overflow-hidden flex mb-2">
<div
className={`h-full ${isDeficit ? 'bg-red-500' : 'bg-indigo-500'}`}
style={{ width: `${Math.min((scheduledTotal / (balance || 1)) * 100, 100)}%` }}
/>
</div>
<p className="text-[9px] text-slate-400 text-right font-medium">Загрузка бюджета на тек. неделю: {Math.round((scheduledTotal / (balance || 1)) * 100)}%</p>
</div>
{/* 2. Actions */}
{isEditing && (
<button onClick={handleAddInvoice} className="w-full p-3 bg-primary-100 text-primary-700 rounded-xl font-bold text-sm flex items-center justify-center gap-2 border border-primary-200 hover:bg-primary-200 transition-colors">
<Plus className="w-4 h-4" /> Добавить счет
</button>
)}
{/* 3. Реестр счетов по дому (единый источник) */}
<div className="bg-white border border-slate-200 rounded-2xl shadow-sm overflow-hidden">
<div className="p-4 bg-slate-50 border-b border-slate-200 flex justify-between items-center">
<div>
<h3 className="font-black text-slate-700 text-[10px] uppercase tracking-widest">Счета по дому</h3>
<p className="text-[10px] text-slate-400 font-bold mt-1">
Источник: финансовый модуль {paymentInvoices.length} шт. Сумма: {paymentInvoicesTotals.total.toLocaleString('ru-RU')}
</p>
</div>
</div>
<div className="divide-y divide-slate-100">
{!loadingPaymentInvoices && paymentInvoices.length === 0 && (
<div className="p-10 text-center text-slate-400 text-sm">Счетов нет</div>
)}
{paymentInvoices.slice(0, 50).map((inv) => (
<div
key={inv.id}
className={`p-4 transition-colors cursor-pointer ${isEditing ? 'bg-primary-50/20' : 'hover:bg-slate-50'}`}
onClick={() => setSelectedPaymentInvoice(inv)}
>
<div className="flex justify-between items-start gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<span
className={`text-[9px] font-black uppercase px-2 py-0.5 rounded ${
paymentStatusConfig[inv.status]?.bg ||
'bg-slate-100'
} ${
paymentStatusConfig[inv.status]?.color ||
'text-slate-600'
}`}
>
{paymentStatusConfig[inv.status]?.label || inv.status}
</span>
<span className="text-xs text-slate-500 font-mono truncate">{inv.invoiceNumber}</span>
{inv.scheduledDate && (
<span className="text-[10px] text-slate-400 font-bold flex items-center gap-1">
<Calendar className="w-3 h-3" /> {new Date(inv.scheduledDate).toLocaleDateString('ru-RU')}
</span>
)}
</div>
<div className="text-sm font-bold text-slate-800 truncate">{inv.contractorName}</div>
<div className="text-xs text-slate-500 truncate">{inv.serviceDescription || '—'}</div>
</div>
<div className="text-right flex-shrink-0">
<div className="font-black text-slate-800">
{Number(inv.totalAmount || 0).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<div className="text-[10px] text-slate-400">
{inv.createdAt ? new Date(inv.createdAt).toLocaleDateString('ru-RU') : ''}
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Modal: просмотр/доработка счета */}
{selectedPaymentInvoice && (
<div className="fixed inset-0 z-[120] bg-slate-900/70 backdrop-blur-sm p-4 flex items-center justify-center">
<div className="w-full max-w-5xl max-h-[90vh] overflow-auto bg-transparent">
<div className="bg-white rounded-2xl border border-slate-200 shadow-2xl overflow-hidden">
<PaymentInvoiceDetail
invoice={selectedPaymentInvoice}
currentUserId={currentUserId}
onBack={() => setSelectedPaymentInvoice(null)}
onUpdate={async () => {
// Перезагружаем счет (чтобы статус/поля обновились)
try {
const fresh = await apiClient.get<PaymentInvoice>(`/finance/payment-invoices/${selectedPaymentInvoice.id}`);
setSelectedPaymentInvoice(fresh);
} catch (e) {
console.warn('[FinanceView] Failed to refresh selected invoice:', e);
}
await refreshPaymentInvoices();
}}
/>
</div>
</div>
</div>
)}
</div>
);
};
const StatusBadge: React.FC<{ status: InvoiceStatus }> = ({ status }) => {
const config: Record<InvoiceStatus, { label: string, color: string, bg: string }> = {
draft: { label: 'Черновик', color: 'text-slate-500', bg: 'bg-slate-100' },
pending_approval: { label: 'На согл.', color: 'text-amber-600', bg: 'bg-amber-50' },
approved: { label: 'Согласован', color: 'text-blue-600', bg: 'bg-blue-50' },
scheduled: { label: 'В графике', color: 'text-indigo-600', bg: 'bg-indigo-50' },
paid: { label: 'Оплачен', color: 'text-emerald-600', bg: 'bg-emerald-50' },
rejected: { label: 'Отказ', color: 'text-red-600', bg: 'bg-red-50' },
overdue: { label: 'Просрочен', color: 'text-white', bg: 'bg-red-500' },
// FIX: Added missing 'clarification' status to config to satisfy Record<InvoiceStatus, ...> type.
clarification: { label: 'Уточнение', color: 'text-purple-600', bg: 'bg-purple-50' },
};
const s = config[status] || config.draft;
return (
<span className={`px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-wider ${s.bg} ${s.color}`}>
{s.label}
</span>
);
};

View File

@@ -0,0 +1,3 @@
import { InspectionsView as Inspections } from './InspectionsView';
export { Inspections };

View 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>
);
};

View File

@@ -0,0 +1,941 @@
import React, { useState, useEffect } from 'react';
import { Building, BuildingInventoryItem, PaymentInvoice, MaterialItem, District, WriteOffAct, WriteOffItem } from '../../types';
import { Package, Hammer, DoorOpen, Droplets, Trash2, Plus, ArrowDownToLine, History, Search, FileText, Check, X, Warehouse } from 'lucide-react';
import { apiClient, backendApi } from '../../services/apiClient';
export const InventoryView: React.FC<{ building: Building, setBuilding: React.Dispatch<React.SetStateAction<Building>>, isEditing: boolean }> = ({ building, setBuilding, isEditing }) => {
const [searchTerm, setSearchTerm] = useState('');
const [filterCategory, setFilterCategory] = useState<'all' | BuildingInventoryItem['category']>('all');
const [writeOffModal, setWriteOffModal] = useState<BuildingInventoryItem | null>(null);
const [writeOffQty, setWriteOffQty] = useState(1);
const [writeOffReason, setWriteOffReason] = useState('Расходные материалы для ремонта');
const [writeOffUnitPrice, setWriteOffUnitPrice] = useState<number>(0); // Цена за единицу при списании
// При открытии модального окна списания устанавливаем цену из товара
useEffect(() => {
if (writeOffModal) {
setWriteOffUnitPrice(writeOffModal.unitPrice || 0);
setWriteOffQty(1);
}
}, [writeOffModal]);
// Модальное окно для импорта из счетов
const [showImportModal, setShowImportModal] = useState(false);
const [paymentInvoices, setPaymentInvoices] = useState<PaymentInvoice[]>([]);
const [loadingInvoices, setLoadingInvoices] = useState(false);
const [selectedInvoice, setSelectedInvoice] = useState<PaymentInvoice | null>(null);
// Map: индекс позиции -> количество для импорта
const [selectedMaterials, setSelectedMaterials] = useState<Map<number, number>>(new Map());
// Модальное окно для взятия со склада участка
const [showDistrictWarehouseModal, setShowDistrictWarehouseModal] = useState(false);
const [districtInventory, setDistrictInventory] = useState<BuildingInventoryItem[]>([]);
const [selectedDistrictItems, setSelectedDistrictItems] = useState<Map<string, number>>(new Map()); // itemId -> quantity
const [district, setDistrict] = useState<District | null>(null);
const inventory = building.inventory || [];
const filteredItems = inventory.filter(item => {
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesFilter = filterCategory === 'all' || item.category === filterCategory;
return matchesSearch && matchesFilter;
});
const handleWriteOff = () => {
if (!writeOffModal) return;
// Используем цену из модального окна (или из товара, если не указана)
const unitPrice = writeOffUnitPrice || writeOffModal.unitPrice || 0;
const itemAmount = writeOffQty * unitPrice;
const writeOffItem: WriteOffItem = {
itemId: writeOffModal.id,
name: writeOffModal.name,
quantity: writeOffQty,
unit: writeOffModal.unit,
unitPrice: unitPrice,
amount: itemAmount,
reason: writeOffReason
};
const act: WriteOffAct = {
id: `wo-${Date.now()}`,
date: new Date().toISOString().split('T')[0],
items: [writeOffItem],
totalAmount: itemAmount, // Общая сумма списания
performer: 'Мастер участка',
reason: writeOffReason
};
setBuilding(prev => ({
...prev,
inventory: prev.inventory.map(item =>
item.id === writeOffModal.id
? { ...item, quantity: Math.max(0, item.quantity - writeOffQty) }
: item
),
writeOffHistory: [act, ...(prev.writeOffHistory || [])],
isDirty: true
}));
setWriteOffModal(null);
};
// Загрузка счетов с ТМЦ для текущего дома
useEffect(() => {
if (!showImportModal) return;
const loadInvoices = async () => {
try {
setLoadingInvoices(true);
const resp = await apiClient.get<{ invoices: PaymentInvoice[] }>(
`/finance/payment-invoices?purposeType=building&limit=200`
);
// Фильтруем: только по текущему дому и только с ТМЦ
const filtered = (resp?.invoices || []).filter((inv) =>
Array.isArray(inv.purposeBuildingIds) &&
inv.purposeBuildingIds.includes(building.id) &&
inv.itemType === 'materials' &&
inv.materialItems &&
inv.materialItems.length > 0
);
setPaymentInvoices(filtered);
} catch (e) {
console.warn('[InventoryView] Failed to load payment invoices:', e);
setPaymentInvoices([]);
} finally {
setLoadingInvoices(false);
}
};
loadInvoices();
}, [showImportModal, building.id]);
// Загрузка склада участка
useEffect(() => {
if (!showDistrictWarehouseModal || !building.districtId) return;
const loadDistrict = async () => {
try {
const districts = await backendApi.getDistricts();
const currentDistrict = districts.find(d => d.id === building.districtId);
if (currentDistrict) {
setDistrict(currentDistrict);
setDistrictInventory(currentDistrict.inventory || []);
} else {
setDistrict(null);
setDistrictInventory([]);
}
} catch (error) {
console.error('Ошибка при загрузке участка:', error);
setDistrict(null);
setDistrictInventory([]);
}
setSelectedDistrictItems(new Map());
};
loadDistrict();
}, [showDistrictWarehouseModal, building.districtId]);
// Добавление выбранных позиций на склад участка (вместо прямого добавления в дом)
const handleImportMaterials = async () => {
if (!selectedInvoice || selectedMaterials.size === 0 || !building.districtId) return;
try {
// Получаем участок с сервера
const districts = await backendApi.getDistricts();
const currentDistrict = districts.find(d => d.id === building.districtId);
if (!currentDistrict) {
alert('Участок не найден');
return;
}
const newItems: BuildingInventoryItem[] = Array.from(selectedMaterials.entries())
.map(([index, quantity]) => {
const material = selectedInvoice.materialItems![index];
if (!material || quantity <= 0) return null;
// Определяем категорию по названию
const nameLower = material.name.toLowerCase();
let category: BuildingInventoryItem['category'] = 'material';
if (nameLower.includes('инструмент') || nameLower.includes('дрель') ||
nameLower.includes('перфоратор') || nameLower.includes('болгарка') ||
nameLower.includes('отвертка') || nameLower.includes('ключ')) {
category = 'tool';
} else if (nameLower.includes('дверь') || nameLower.includes('окно') ||
nameLower.includes('рама') || nameLower.includes('блок')) {
category = 'door';
} else if (nameLower.includes('расход') || nameLower.includes('салфетк') ||
nameLower.includes('перчатк') || nameLower.includes('скотч')) {
category = 'consumable';
}
// Рассчитываем общую стоимость для выбранного количества
const totalAmount = quantity * material.pricePerUnit;
return {
id: `inv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: material.name,
category,
quantity: quantity,
unit: material.unit,
lastCheck: new Date().toISOString().split('T')[0],
source: 'invoice' as const,
unitPrice: material.pricePerUnit, // Цена за единицу (стоимость / количество)
totalAmount: totalAmount // Общая стоимость товара
};
})
.filter((item): item is BuildingInventoryItem => item !== null);
// Добавляем на склад участка (объединяем с существующими позициями)
const existingInventory = currentDistrict.inventory || [];
const updatedInventory = [...existingInventory];
newItems.forEach(newItem => {
// Ищем существующую позицию с таким же названием и единицей измерения
const existingIndex = updatedInventory.findIndex(
item => item.name === newItem.name && item.unit === newItem.unit
);
if (existingIndex >= 0) {
// Объединяем с существующей позицией
const existing = updatedInventory[existingIndex];
const newQuantity = existing.quantity + newItem.quantity;
const newTotalAmount = (existing.totalAmount || 0) + (newItem.totalAmount || 0);
// Пересчитываем цену за единицу на основе общей стоимости
const newUnitPrice = newQuantity > 0 ? newTotalAmount / newQuantity : (newItem.unitPrice || existing.unitPrice || 0);
updatedInventory[existingIndex] = {
...existing,
quantity: newQuantity,
totalAmount: newTotalAmount,
unitPrice: newUnitPrice
};
} else {
// Добавляем новую позицию
updatedInventory.push(newItem);
}
});
// Сохраняем обновленный склад участка через API
await backendApi.updateDistrict(currentDistrict.id, {
inventory: updatedInventory
});
alert(`Добавлено ${newItems.length} позиций на склад участка "${currentDistrict.name}"`);
// Закрываем модальное окно и сбрасываем состояние
setShowImportModal(false);
setSelectedInvoice(null);
setSelectedMaterials(new Map());
} catch (error) {
console.error('Ошибка при сохранении на склад участка:', error);
alert('Ошибка при сохранении на склад участка');
}
};
const toggleMaterialSelection = (index: number, material: MaterialItem) => {
setSelectedMaterials(prev => {
const newMap = new Map(prev);
if (newMap.has(index)) {
newMap.delete(index);
} else {
// По умолчанию ставим максимальное количество из счета
newMap.set(index, material.quantity);
}
return newMap;
});
};
const updateMaterialQuantity = (index: number, quantity: number, maxQuantity: number) => {
const validQuantity = Math.max(0, Math.min(quantity, maxQuantity));
setSelectedMaterials(prev => {
const newMap = new Map(prev);
if (validQuantity > 0) {
newMap.set(index, validQuantity);
} else {
newMap.delete(index);
}
return newMap;
});
};
// Взятие позиций со склада участка в дом
const handleTakeFromDistrict = async () => {
if (!district || selectedDistrictItems.size === 0) return;
try {
const itemsToAdd: BuildingInventoryItem[] = [];
const updatedDistrictInventory = [...districtInventory];
selectedDistrictItems.forEach((quantity, itemId) => {
const districtItem = districtInventory.find(item => item.id === itemId);
if (!districtItem || quantity <= 0 || quantity > districtItem.quantity) return;
// Добавляем в дом
const existingItemIndex = building.inventory.findIndex(
item => item.name === districtItem.name && item.unit === districtItem.unit
);
if (existingItemIndex >= 0) {
// Объединяем с существующей позицией в доме (пересчитываем общую стоимость и цену)
setBuilding(prev => {
const existing = prev.inventory[existingItemIndex];
const newQuantity = existing.quantity + quantity;
const itemTotalAmount = quantity * (districtItem.unitPrice || 0);
const newTotalAmount = (existing.totalAmount || 0) + itemTotalAmount;
const newUnitPrice = newQuantity > 0 ? newTotalAmount / newQuantity : (districtItem.unitPrice || existing.unitPrice || 0);
return {
...prev,
inventory: prev.inventory.map((item, idx) =>
idx === existingItemIndex
? {
...item,
quantity: newQuantity,
totalAmount: newTotalAmount,
unitPrice: newUnitPrice
}
: item
),
isDirty: true
};
});
} else {
// Добавляем новую позицию в дом (сохраняем цену и общую стоимость)
const itemTotalAmount = quantity * (districtItem.unitPrice || 0);
itemsToAdd.push({
...districtItem,
id: `inv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
quantity: quantity,
source: 'district' as const,
unitPrice: districtItem.unitPrice, // Сохраняем цену при переносе со склада участка
totalAmount: itemTotalAmount // Рассчитываем общую стоимость для перенесенного количества
});
}
// Уменьшаем количество на складе участка и пересчитываем общую стоимость
const districtItemIndex = updatedDistrictInventory.findIndex(item => item.id === itemId);
if (districtItemIndex >= 0) {
const existing = updatedDistrictInventory[districtItemIndex];
const newQuantity = existing.quantity - quantity;
if (newQuantity > 0) {
// Пересчитываем общую стоимость пропорционально оставшемуся количеству
const unitPrice = existing.unitPrice || 0;
const newTotalAmount = newQuantity * unitPrice;
updatedDistrictInventory[districtItemIndex] = {
...existing,
quantity: newQuantity,
totalAmount: newTotalAmount
};
} else {
updatedDistrictInventory.splice(districtItemIndex, 1);
}
}
});
// Обновляем склад участка через API
await backendApi.updateDistrict(district.id, {
inventory: updatedDistrictInventory
});
setDistrictInventory(updatedDistrictInventory);
// Добавляем новые позиции в дом
if (itemsToAdd.length > 0) {
setBuilding(prev => ({
...prev,
inventory: [...prev.inventory, ...itemsToAdd],
isDirty: true
}));
}
// Закрываем модальное окно
setShowDistrictWarehouseModal(false);
setSelectedDistrictItems(new Map());
} catch (error) {
console.error('Ошибка при взятии со склада участка:', error);
alert('Ошибка при взятии со склада участка');
}
};
const toggleDistrictItemSelection = (itemId: string, item: BuildingInventoryItem) => {
setSelectedDistrictItems(prev => {
const newMap = new Map(prev);
if (newMap.has(itemId)) {
newMap.delete(itemId);
} else {
newMap.set(itemId, item.quantity);
}
return newMap;
});
};
const updateDistrictItemQuantity = (itemId: string, quantity: number, maxQuantity: number) => {
const validQuantity = Math.max(0, Math.min(quantity, maxQuantity));
setSelectedDistrictItems(prev => {
const newMap = new Map(prev);
if (validQuantity > 0) {
newMap.set(itemId, validQuantity);
} else {
newMap.delete(itemId);
}
return newMap;
});
};
const categories = [
{ id: 'all', label: 'Все', icon: Package },
{ id: 'material', label: 'Материалы', icon: Droplets },
{ id: 'tool', label: 'Инструменты', icon: Hammer },
{ id: 'door', label: 'Двери/Окна', icon: DoorOpen },
{ id: 'consumable', label: 'Расходники', icon: Package },
];
return (
<div className="space-y-6 animate-fade-in">
{/* Stats Header */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
<p className="text-[10px] text-slate-400 font-bold uppercase">Всего позиций</p>
<p className="text-xl font-black text-slate-800">{inventory.length}</p>
</div>
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
<p className="text-[10px] text-slate-400 font-bold uppercase">Списано в этом мес.</p>
<p className="text-xl font-black text-red-600">{(building.writeOffHistory || []).length}</p>
</div>
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm">
<p className="text-[10px] text-slate-400 font-bold uppercase">Сумма списаний</p>
<p className="text-xl font-black text-red-600">
{(building.writeOffHistory || []).reduce((sum, wo) => sum + (wo.totalAmount || 0), 0).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
</div>
{/* Filter Bar */}
<div className="flex gap-2 overflow-x-auto no-scrollbar pb-2">
{categories.map(cat => (
<button
key={cat.id}
onClick={() => setFilterCategory(cat.id as any)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold whitespace-nowrap transition-all ${filterCategory === cat.id ? 'bg-primary-600 text-white shadow-md shadow-primary-200' : 'bg-white text-slate-500 border border-slate-200 hover:border-primary-300'}`}
>
<cat.icon className="w-3.5 h-3.5"/>
{cat.label}
</button>
))}
</div>
{/* Search and Add Button */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Поиск по инвентарю..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm bg-white"
/>
</div>
<button
onClick={() => setShowDistrictWarehouseModal(true)}
className="px-4 py-2.5 bg-emerald-600 text-white rounded-xl font-bold text-xs flex items-center gap-2 hover:bg-emerald-700 transition-all shadow-md shadow-emerald-200"
>
<Warehouse className="w-4 h-4"/> Взять со склада участка
</button>
<button
onClick={() => setShowImportModal(true)}
className="px-4 py-2.5 bg-primary-600 text-white rounded-xl font-bold text-xs flex items-center gap-2 hover:bg-primary-700 transition-all shadow-md shadow-primary-200"
>
<FileText className="w-4 h-4"/> Добавить из счета
</button>
</div>
{/* Inventory List */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-slate-50 text-slate-500 font-bold border-b border-slate-200 text-[10px] uppercase tracking-wider">
<tr>
<th className="p-4">Наименование</th>
<th className="p-4 text-center">Кол-во</th>
<th className="p-4 text-center">Цена за ед.</th>
<th className="p-4 text-center">Общая стоимость</th>
<th className="p-4 text-center">Источник</th>
<th className="p-4 text-right">Действие</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredItems.map(item => (
<tr key={item.id} className="hover:bg-slate-50 transition-colors">
<td className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-100 rounded-lg text-slate-400">
{item.category === 'tool' ? <Hammer className="w-4 h-4"/> : item.category === 'door' ? <DoorOpen className="w-4 h-4"/> : <Package className="w-4 h-4"/>}
</div>
<div>
<p className="font-bold text-slate-700">{item.name}</p>
<p className="text-[10px] text-slate-400 uppercase">{item.category}</p>
</div>
</div>
</td>
<td className="p-4 text-center">
<span className={`px-2 py-1 rounded text-xs font-bold ${item.quantity < 3 && item.category === 'consumable' ? 'bg-red-50 text-red-600' : 'bg-slate-100 text-slate-700'}`}>
{item.quantity} {item.unit}
</span>
</td>
<td className="p-4 text-center">
{item.unitPrice ? (
<span className="px-2 py-1 rounded text-xs font-bold bg-blue-50 text-blue-700">
{item.unitPrice.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} /{item.unit}
</span>
) : (
<span className="text-xs text-slate-400"></span>
)}
</td>
<td className="p-4 text-center">
{item.totalAmount ? (
<span className="px-2 py-1 rounded text-xs font-bold bg-green-50 text-green-700">
{item.totalAmount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
) : (
<span className="text-xs text-slate-400"></span>
)}
</td>
<td className="p-4 text-center">
<span className={`px-2 py-1 rounded text-xs font-bold ${
item.source === 'district'
? 'bg-emerald-50 text-emerald-700'
: item.source === 'invoice'
? 'bg-blue-50 text-blue-700'
: 'bg-slate-100 text-slate-500'
}`}>
{item.source === 'district' ? 'Склад участка' : item.source === 'invoice' ? 'Из счета' : 'Не указан'}
</span>
</td>
<td className="p-4 text-right">
<button
onClick={() => setWriteOffModal(item)}
className="text-[10px] font-bold text-red-600 bg-red-50 px-3 py-1.5 rounded-lg border border-red-100 hover:bg-red-100 transition-colors flex items-center gap-1 ml-auto"
>
<ArrowDownToLine className="w-3.5 h-3.5"/> Списать
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredItems.length === 0 && (
<div className="p-12 text-center text-slate-400 italic">Позиции не найдены</div>
)}
</div>
{/* Write-off History */}
<div>
<h3 className="font-bold text-slate-700 text-sm mb-3 px-1 flex items-center gap-2">
<History className="w-4 h-4"/> История списаний
</h3>
<div className="space-y-3">
{(building.writeOffHistory || []).map(wo => (
<div key={wo.id} className="bg-slate-50 p-3 rounded-xl border border-slate-100 text-xs">
<div className="flex justify-between items-start mb-2">
<div className="flex-1">
<span className="font-bold text-slate-800">{wo.items[0].name}</span>
<span className="text-slate-500 ml-2">({wo.items[0].quantity} {wo.items[0].unit})</span>
</div>
<div className="text-right">
<div className="font-black text-red-600 text-sm">
{wo.totalAmount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<div className="text-slate-400 font-mono text-[10px] mt-1">{wo.date}</div>
</div>
</div>
<div className="flex justify-between items-center mt-2">
<p className="text-slate-500 italic text-[11px]">Причина: {wo.items[0].reason || wo.reason || 'Не указана'}</p>
<p className="text-[10px] text-slate-400">Отв: {wo.performer}</p>
</div>
{wo.items[0].unitPrice > 0 && (
<p className="text-[10px] text-slate-400 mt-1">
Цена: {wo.items[0].unitPrice.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} /{wo.items[0].unit}
</p>
)}
</div>
))}
{(building.writeOffHistory || []).length === 0 && (
<p className="text-center text-slate-400 italic text-xs py-4">Списаний еще не было</p>
)}
</div>
</div>
{/* Write-off Modal */}
{writeOffModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={() => {
setWriteOffModal(null);
setWriteOffQty(1);
setWriteOffUnitPrice(writeOffModal.unitPrice || 0);
}}>
<div className="bg-white rounded-2xl w-full max-w-sm p-6 shadow-2xl animate-slide-up" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-bold text-slate-800 mb-2">Списание со склада дома</h3>
<p className="text-sm text-slate-500 mb-6">{writeOffModal.name}</p>
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Количество</label>
<div className="flex items-center gap-4">
<input
type="range" min="1" max={writeOffModal.quantity}
value={writeOffQty}
onChange={e => setWriteOffQty(Number(e.target.value))}
className="flex-1 accent-primary-600"
/>
<span className="text-lg font-black text-slate-800 w-12 text-center">{writeOffQty}</span>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Цена за единицу ()</label>
<input
type="number"
min="0"
step="0.01"
value={writeOffUnitPrice}
onChange={e => setWriteOffUnitPrice(Number(e.target.value))}
placeholder={writeOffModal.unitPrice ? writeOffModal.unitPrice.toString() : "0.00"}
className="w-full p-3 bg-slate-50 rounded-xl border border-slate-200 text-sm outline-none focus:ring-2 focus:ring-primary-500"
/>
{writeOffModal.unitPrice && (
<p className="text-xs text-slate-400 mt-1">Текущая цена: {writeOffModal.unitPrice.toLocaleString('ru-RU')} </p>
)}
</div>
<div className="bg-red-50 p-3 rounded-xl border border-red-100">
<div className="flex justify-between items-center">
<span className="text-xs font-bold text-red-700 uppercase">Сумма списания:</span>
<span className="text-lg font-black text-red-700">
{(writeOffQty * writeOffUnitPrice).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-400 uppercase mb-1">Причина списания</label>
<textarea
value={writeOffReason}
onChange={e => setWriteOffReason(e.target.value)}
className="w-full p-3 bg-slate-50 rounded-xl border border-slate-200 text-sm outline-none focus:ring-2 focus:ring-primary-500 min-h-[80px]"
/>
</div>
</div>
<div className="mt-8 flex gap-3">
<button onClick={() => {
setWriteOffModal(null);
setWriteOffQty(1);
setWriteOffUnitPrice(writeOffModal.unitPrice || 0);
}} className="flex-1 py-3 text-slate-600 font-bold bg-slate-100 rounded-xl">Отмена</button>
<button onClick={handleWriteOff} className="flex-1 py-3 text-white font-bold bg-red-600 rounded-xl hover:bg-red-700">Списать</button>
</div>
</div>
</div>
)}
{/* Import from Invoice Modal */}
{showImportModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={() => setShowImportModal(false)}>
<div className="bg-white rounded-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden shadow-2xl animate-slide-up flex flex-col" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-200 flex justify-between items-center">
<div>
<h3 className="text-lg font-bold text-slate-800">Добавить инвентарь из счета</h3>
<p className="text-sm text-slate-500 mt-1">Выберите счет с ТМЦ и позиции для добавления</p>
</div>
<button onClick={() => setShowImportModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
<X className="w-5 h-5 text-slate-400"/>
</button>
</div>
<div className="flex-1 overflow-auto p-6">
{loadingInvoices ? (
<div className="text-center py-12 text-slate-400">Загрузка счетов...</div>
) : paymentInvoices.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<FileText className="w-12 h-12 mx-auto mb-3 opacity-20"/>
<p>Нет счетов с ТМЦ для этого дома</p>
</div>
) : !selectedInvoice ? (
<div className="space-y-3">
<h4 className="font-bold text-slate-700 text-sm mb-4">Выберите счет:</h4>
{paymentInvoices.map((inv) => (
<div
key={inv.id}
onClick={() => setSelectedInvoice(inv)}
className="p-4 border border-slate-200 rounded-xl hover:border-primary-400 hover:bg-primary-50/20 cursor-pointer transition-all"
>
<div className="flex justify-between items-start">
<div>
<div className="font-bold text-slate-800">{inv.invoiceNumber}</div>
<div className="text-sm text-slate-600 mt-1">{inv.contractorName}</div>
<div className="text-xs text-slate-400 mt-1">
{inv.materialItems?.length || 0} позиций ТМЦ {new Date(inv.createdAt).toLocaleDateString('ru-RU')}
</div>
</div>
<div className="text-right">
<div className="font-black text-slate-800">{Number(inv.totalAmount || 0).toLocaleString('ru-RU')} </div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between pb-4 border-b border-slate-200">
<div>
<h4 className="font-bold text-slate-800">{selectedInvoice.invoiceNumber}</h4>
<p className="text-sm text-slate-500">{selectedInvoice.contractorName}</p>
</div>
<button
onClick={() => {
setSelectedInvoice(null);
setSelectedMaterials(new Map());
}}
className="text-xs text-slate-500 hover:text-slate-700 flex items-center gap-1"
>
<X className="w-4 h-4"/> Выбрать другой счет
</button>
</div>
<div className="space-y-2">
<h5 className="font-bold text-slate-700 text-sm">Выберите позиции и укажите количество:</h5>
{selectedInvoice.materialItems?.map((material, index) => {
const isSelected = selectedMaterials.has(index);
const selectedQty = selectedMaterials.get(index) || 0;
return (
<div
key={index}
className={`p-4 border-2 rounded-xl transition-all ${
isSelected
? 'border-primary-500 bg-primary-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<div
onClick={() => toggleMaterialSelection(index, material)}
className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 mt-0.5 cursor-pointer ${
isSelected
? 'bg-primary-500 border-primary-500'
: 'border-slate-300'
}`}
>
{isSelected && (
<Check className="w-3 h-3 text-white"/>
)}
</div>
<div className="flex-1">
<div className="font-bold text-slate-800">{material.name}</div>
<div className="text-sm text-slate-500 mt-1">
В счете: {material.quantity} {material.unit} × {material.pricePerUnit.toLocaleString('ru-RU')} = {material.amount.toLocaleString('ru-RU')}
</div>
</div>
</div>
{isSelected && (
<div className="flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<label className="text-xs text-slate-500 font-bold whitespace-nowrap">Кол-во:</label>
<input
type="number"
min="1"
max={material.quantity}
value={selectedQty}
onChange={(e) => {
const val = Number(e.target.value);
updateMaterialQuantity(index, val, material.quantity);
}}
onClick={(e) => e.stopPropagation()}
className="w-20 px-2 py-1 border border-slate-300 rounded-lg text-sm font-bold text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<span className="text-xs text-slate-500">{material.unit}</span>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{selectedInvoice && selectedMaterials.size > 0 && (
<div className="p-6 border-t border-slate-200 bg-slate-50 flex justify-between items-center">
<div className="text-sm text-slate-600">
Выбрано позиций: <span className="font-bold text-slate-800">{selectedMaterials.size}</span>
{' • '}
Всего единиц: <span className="font-bold text-slate-800">
{Array.from(selectedMaterials.values()).reduce((sum, qty) => sum + qty, 0)}
</span>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setShowImportModal(false);
setSelectedInvoice(null);
setSelectedMaterials(new Map());
}}
className="px-4 py-2 text-slate-600 font-bold bg-white border border-slate-200 rounded-xl hover:bg-slate-50"
>
Отмена
</button>
<button
onClick={handleImportMaterials}
className="px-4 py-2 bg-primary-600 text-white font-bold rounded-xl hover:bg-primary-700 flex items-center gap-2"
>
<Plus className="w-4 h-4"/> Добавить выбранные
</button>
</div>
</div>
)}
</div>
</div>
)}
{/* Take from District Warehouse Modal */}
{showDistrictWarehouseModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={() => setShowDistrictWarehouseModal(false)}>
<div className="bg-white rounded-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden shadow-2xl animate-slide-up flex flex-col" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-200 flex justify-between items-center">
<div>
<h3 className="text-lg font-bold text-slate-800">Взять со склада участка</h3>
<p className="text-sm text-slate-500 mt-1">
{district ? `Склад участка: ${district.name}` : 'Участок не найден'}
</p>
</div>
<button onClick={() => setShowDistrictWarehouseModal(false)} className="p-2 hover:bg-slate-100 rounded-full">
<X className="w-5 h-5 text-slate-400"/>
</button>
</div>
<div className="flex-1 overflow-auto p-6">
{!district ? (
<div className="text-center py-12 text-slate-400">
<Warehouse className="w-12 h-12 mx-auto mb-3 opacity-20"/>
<p>Участок не найден</p>
</div>
) : districtInventory.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<Warehouse className="w-12 h-12 mx-auto mb-3 opacity-20"/>
<p>Склад участка пуст</p>
</div>
) : (
<div className="space-y-2">
<h5 className="font-bold text-slate-700 text-sm mb-4">Выберите позиции и укажите количество:</h5>
{districtInventory.map((item) => {
const isSelected = selectedDistrictItems.has(item.id);
const selectedQty = selectedDistrictItems.get(item.id) || 0;
return (
<div
key={item.id}
className={`p-4 border-2 rounded-xl transition-all ${
isSelected
? 'border-emerald-500 bg-emerald-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<div
onClick={() => toggleDistrictItemSelection(item.id, item)}
className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 mt-0.5 cursor-pointer ${
isSelected
? 'bg-emerald-500 border-emerald-500'
: 'border-slate-300'
}`}
>
{isSelected && (
<Check className="w-3 h-3 text-white"/>
)}
</div>
<div className="flex-1">
<div className="font-bold text-slate-800">{item.name}</div>
<div className="text-sm text-slate-500 mt-1">
На складе: {item.quantity} {item.unit}
</div>
{item.unitPrice && (
<div className="text-xs text-blue-600 mt-1">
Цена: {item.unitPrice.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} /{item.unit}
</div>
)}
{item.totalAmount && (
<div className="text-xs text-green-600 mt-1 font-bold">
Общая стоимость: {item.totalAmount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
)}
<div className="text-xs text-slate-400 mt-1 uppercase">
{item.category}
</div>
</div>
</div>
{isSelected && (
<div className="flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<label className="text-xs text-slate-500 font-bold whitespace-nowrap">Кол-во:</label>
<input
type="number"
min="1"
max={item.quantity}
value={selectedQty}
onChange={(e) => {
const val = Number(e.target.value);
updateDistrictItemQuantity(item.id, val, item.quantity);
}}
onClick={(e) => e.stopPropagation()}
className="w-20 px-2 py-1 border border-slate-300 rounded-lg text-sm font-bold text-slate-800 focus:outline-none focus:ring-2 focus:ring-emerald-500"
/>
<span className="text-xs text-slate-500">{item.unit}</span>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{district && selectedDistrictItems.size > 0 && (
<div className="p-6 border-t border-slate-200 bg-slate-50 flex justify-between items-center">
<div className="text-sm text-slate-600">
Выбрано позиций: <span className="font-bold text-slate-800">{selectedDistrictItems.size}</span>
{' • '}
Всего единиц: <span className="font-bold text-slate-800">
{Array.from(selectedDistrictItems.values()).reduce((sum, qty) => sum + qty, 0)}
</span>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setShowDistrictWarehouseModal(false);
setSelectedDistrictItems(new Map());
}}
className="px-4 py-2 text-slate-600 font-bold bg-white border border-slate-200 rounded-xl hover:bg-slate-50"
>
Отмена
</button>
<button
onClick={handleTakeFromDistrict}
className="px-4 py-2 bg-emerald-600 text-white font-bold rounded-xl hover:bg-emerald-700 flex items-center gap-2"
>
<Warehouse className="w-4 h-4"/> Взять выбранные
</button>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,3 @@
import { MeterCheckView as MeterCheck } from './MeterCheckView';
export { MeterCheck };

File diff suppressed because it is too large Load Diff

293
components/building/Overview.tsx Executable file
View File

@@ -0,0 +1,293 @@
import React, { useState, useEffect } from 'react';
import { Building, BuildingTask, Employee } from '../../types';
import {
Users,
Wallet,
Activity,
TrendingDown,
CheckCircle2,
Calendar,
User,
Flag,
Clock,
MessageSquare,
Camera,
Image as ImageIcon
} from 'lucide-react';
import { TaskModal } from './TaskModal';
import { OutagesJournal } from '../applications/OutagesJournal';
import { backendApi } from '../../services/apiClient';
import { storageService } from '../../services/storageService';
export const Overview: React.FC<{ building: Building, onNavigate: (tab: any) => void, setBuilding?: React.Dispatch<React.SetStateAction<Building>> }> = ({ building, onNavigate, setBuilding }) => {
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState<BuildingTask | null>(null);
const [employees, setEmployees] = useState<Employee[]>([]);
useEffect(() => {
fetchEmployees();
}, []);
const fetchEmployees = async () => {
try {
const fetchedEmployees = await backendApi.getEmployees();
setEmployees(fetchedEmployees);
} catch (error) {
console.error('Failed to fetch employees:', error);
}
};
const handleTaskClick = (task: BuildingTask) => {
setSelectedTask(task);
setIsTaskModalOpen(true);
};
const handleNewTask = () => {
setSelectedTask(null);
setIsTaskModalOpen(true);
};
const tasks = building.tasks ?? [];
const handleSaveTask = (task: BuildingTask) => {
const updatedTasks = task.id && tasks.find(t => t.id === task.id)
? tasks.map(t => t.id === task.id ? task : t)
: [...tasks, task];
const updatedBuilding = {
...building,
tasks: updatedTasks,
isDirty: true
};
if (setBuilding) {
setBuilding(updatedBuilding);
}
// Сохраняем в localStorage
storageService.saveBuildingData(updatedBuilding);
// Пытаемся сохранить на сервер
backendApi.updateBuilding(updatedBuilding).catch(err => {
console.error('Failed to save task to backend:', err);
});
};
const formatDate = (dateString: string) => {
if (!dateString) return '—';
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
};
const getPriorityColor = (priority: BuildingTask['priority']) => {
switch (priority) {
case 'urgent': return 'bg-red-50 text-red-600';
case 'high': return 'bg-amber-50 text-amber-600';
case 'medium': return 'bg-blue-50 text-blue-600';
case 'low': return 'bg-slate-100 text-slate-600';
default: return 'bg-slate-100 text-slate-600';
}
};
const getPriorityLabel = (priority: BuildingTask['priority']) => {
switch (priority) {
case 'urgent': return 'Срочно';
case 'high': return 'Высокий';
case 'medium': return 'Средний';
case 'low': return 'Низкий';
default: return priority;
}
};
// Текущий месяц для блока «Статус работ»
const MONTH_NAMES = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
const now = new Date();
const currentYear = now.getFullYear();
const currentMonthIndex = now.getMonth();
const currentMonthName = MONTH_NAMES[currentMonthIndex];
const currentMonthNameLower = currentMonthName.charAt(0).toLowerCase() + currentMonthName.slice(1);
const annualPlan = building.annualPlan ?? [];
const plansForCurrentMonth = annualPlan.filter(
p => (p.month === currentMonthName || p.month?.toLowerCase() === currentMonthNameLower) && (p.year === currentYear || p.year == null)
);
return (
<div className="space-y-6 animate-fade-in">
<div className="bg-white rounded-3xl shadow-sm border border-slate-200 overflow-hidden">
<div className="h-56 w-full bg-slate-200 relative">
<img src={building.imageUrl} alt="Building" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent p-8 flex flex-col justify-end">
<h2 className="text-3xl font-black text-white leading-tight">{building.passport?.address ?? 'Адрес не указан'}</h2>
<p className="text-white/70 text-sm mt-1 font-bold uppercase tracking-widest">
{building.passport?.general?.constructionYear ?? '—'} г. {building.passport?.general?.floors ?? '—'} эт. {building.passport?.general?.totalArea ?? '—'} м²
</p>
</div>
</div>
<div className="p-6 grid grid-cols-2 md:grid-cols-4 gap-6 divide-x divide-slate-100 bg-white">
<div className="text-center px-2 cursor-pointer group" onClick={() => onNavigate('accounts')}>
<p className="text-slate-400 text-[10px] font-black uppercase tracking-widest mb-1">Жителей</p>
<p className="text-2xl font-black text-slate-800 group-hover:text-primary-600 transition-colors">
{(building.accounts ?? []).reduce((sum, acc) => {
const count = acc.registered?.length || acc.registeredCount || 0;
return sum + (typeof count === 'number' ? count : 0);
}, 0)}
</p>
</div>
<div className="text-center px-4 cursor-pointer group" onClick={() => onNavigate('finance')}>
<p className="text-slate-400 text-[10px] font-black uppercase tracking-widest mb-1">Баланс</p>
<p className={`text-2xl font-black group-hover:scale-105 transition-transform ${(building.financials?.balance ?? 0) >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
{((building.financials?.balance ?? 0) / 1000).toFixed(0)}k
</p>
</div>
<div className="text-center px-4 cursor-pointer group" onClick={() => onNavigate('inspections')}>
<p className="text-slate-400 text-[10px] font-black uppercase tracking-widest mb-1">Состояние</p>
<p className="text-2xl font-black text-slate-800 flex items-center justify-center gap-2">
<Activity className="w-5 h-5 text-amber-500"/> <span className="hidden sm:inline">Удовл.</span>
</p>
</div>
<div className="text-center px-4">
<p className="text-slate-400 text-[10px] font-black uppercase tracking-widest mb-1">NPS</p>
<p className={`text-2xl font-black ${(building.nps ?? 0) >= 0 ? 'text-emerald-500' : 'text-red-500'}`}>
{(building.nps ?? 0) > 0 ? '+' : ''}{building.nps ?? 0}
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-sm">
<h3 className="font-black text-slate-800 text-[10px] uppercase tracking-[0.2em] mb-6 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-primary-500"/> Оперативный план
</h3>
<div className="space-y-4">
{tasks.map(task => (
<div
key={task.id}
className="flex items-start gap-4 p-3 hover:bg-slate-50 rounded-2xl transition-colors border border-transparent hover:border-slate-100"
>
<button
onClick={(e) => {
e.stopPropagation();
const newStatus = task.status === 'done' ? 'new' : 'done';
const updatedTask = {
...task,
status: newStatus as BuildingTask['status'],
updatedAt: new Date().toISOString()
};
handleSaveTask(updatedTask);
}}
className={`mt-1 w-5 h-5 rounded-full border-2 flex-shrink-0 cursor-pointer transition-all flex items-center justify-center ${
task.status === 'done'
? 'bg-emerald-500 border-emerald-500 hover:bg-emerald-600'
: 'border-slate-300 hover:border-primary-400 bg-white'
}`}
title={task.status === 'done' ? 'Отметить как невыполненную' : 'Отметить как выполненную'}
>
{task.status === 'done' && (
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<div
onClick={() => handleTaskClick(task)}
className="flex-1 min-w-0 cursor-pointer"
>
<p className={`text-sm font-bold truncate ${task.status === 'done' ? 'text-slate-400 line-through' : 'text-slate-800'}`}>{task.title}</p>
<div className="flex flex-wrap items-center gap-2 mt-1.5">
<span className="text-[10px] text-slate-400 font-bold flex items-center gap-1" title="Крайняя дата">
<Calendar className="w-3 h-3"/> {formatDate(task.deadline)}
</span>
<span className={`px-1.5 py-0.5 rounded text-[9px] font-black uppercase ${getPriorityColor(task.priority)}`}>
{getPriorityLabel(task.priority)}
</span>
{task.assignedToName && (
<span className="text-[10px] text-slate-500 font-bold flex items-center gap-1" title="Ответственный">
<User className="w-3 h-3"/> {task.assignedToName}
</span>
)}
{task.createdByName && (
<span className="text-[10px] text-slate-500 font-bold flex items-center gap-1" title="Постановщик">
{task.createdByName}
</span>
)}
{task.estimatedHours && (
<span className="text-[10px] text-slate-500 font-bold flex items-center gap-1">
<Clock className="w-3 h-3"/> {task.estimatedHours}ч
</span>
)}
{(task.comments?.length ?? 0) > 0 && (
<span className="text-[10px] text-slate-500 font-bold flex items-center gap-1" title="Комментарии">
<MessageSquare className="w-3 h-3"/> {task.comments!.length} коммент.
</span>
)}
{task.requirePhotoReport !== false && (
<span className="text-[10px] font-bold flex items-center gap-1" title={task.photoReportId ? 'Фото отчёт приложен' : 'Требуется фото отчёт'}>
{task.photoReportId ? (
<ImageIcon className="w-3 h-3 text-emerald-600"/>
) : (
<Camera className="w-3 h-3 text-amber-500"/>
)}
{task.photoReportId ? 'Фото приложен' : 'Нужен фото'}
</span>
)}
</div>
</div>
</div>
))}
{tasks.length === 0 && (
<p className="text-center py-6 text-slate-400 italic text-sm">Нет задач</p>
)}
<button
onClick={handleNewTask}
className="w-full py-3 text-[10px] font-black uppercase tracking-widest text-primary-600 border-2 border-dashed border-primary-100 rounded-2xl hover:bg-primary-50 transition-colors mt-2"
>
+ Новая задача
</button>
</div>
</div>
<div className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-sm">
<h3 className="font-black text-slate-800 text-[10px] uppercase tracking-[0.2em] mb-6 flex items-center gap-2">
<Calendar className="w-4 h-4 text-primary-500"/> Статус работ за {currentMonthNameLower}
</h3>
<div className="space-y-6">
{plansForCurrentMonth.map(plan => (
<div key={plan.id}>
<div className="flex justify-between items-end mb-2">
<span className="font-bold text-slate-700 text-xs">{plan.workName}</span>
<span className="text-[10px] font-black text-primary-600 bg-primary-50 px-1.5 rounded">{plan.progress}%</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-1.5 overflow-hidden">
<div className={`h-full transition-all duration-1000 ${plan.progress === 100 ? 'bg-emerald-500' : 'bg-primary-500'}`} style={{ width: `${Math.min(100, Math.max(0, plan.progress ?? 0))}%` }}></div>
</div>
</div>
))}
{plansForCurrentMonth.length === 0 && <p className="text-center py-10 text-slate-400 italic text-sm">Работ на {currentMonthNameLower} не запланировано</p>}
</div>
</div>
{/* Журнал отключений по дому */}
<div className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-sm">
<OutagesJournal buildingId={building.id} compact />
</div>
</div>
{isTaskModalOpen && (
<TaskModal
isOpen={isTaskModalOpen}
onClose={() => {
setIsTaskModalOpen(false);
setSelectedTask(null);
}}
onSave={handleSaveTask}
task={selectedTask}
buildingId={building.id}
employees={employees}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,147 @@
import React, { useState } from 'react';
import { Building } from '../../types';
import { generateBuildingAudit } from '../../services/geminiService';
import {
Users,
Wallet,
Activity,
TrendingDown,
Bot,
Sparkles,
CheckCircle2,
Calendar
} from 'lucide-react';
type TabType = 'overview' | 'finance' | 'inspections' | 'residents' | 'passport' | 'accounts';
export const OverviewView: React.FC<{ building: Building, onNavigate: (tab: TabType) => void }> = ({ building, onNavigate }) => {
const [auditResult, setAuditResult] = useState<string | null>(null);
const [loadingAudit, setLoadingAudit] = useState(false);
const handleAudit = async () => {
setLoadingAudit(true);
const res = await generateBuildingAudit(building);
setAuditResult(res);
setLoadingAudit(false);
};
return (
<div className="space-y-6 animate-fade-in">
{/* Main Info Card */}
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
<div className="h-48 w-full bg-slate-200 relative">
<img src={building.imageUrl} alt="Building" className="w-full h-full object-cover" />
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-6">
<div className="flex justify-between items-end text-white">
<div>
<h2 className="text-2xl font-bold">{building.passport.address}</h2>
<p className="text-sm opacity-90">{building.passport.general.constructionYear} г. {building.passport.general.floors} эт. {building.passport.general.totalArea} м²</p>
</div>
<div className="text-right">
<p className="text-sm opacity-75">NPS</p>
<p className={`text-3xl font-black ${building.nps >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>{building.nps > 0 ? '+' : ''}{building.nps}</p>
</div>
</div>
</div>
</div>
<div className="p-4 grid grid-cols-2 md:grid-cols-4 gap-4 divide-x divide-slate-100">
<div className="text-center px-2 cursor-pointer hover:bg-slate-50 transition-colors rounded" onClick={() => onNavigate('residents')}>
<p className="text-slate-400 text-xs font-bold uppercase">Жителей</p>
<p className="text-xl font-black text-slate-800 flex items-center justify-center gap-1">
<Users className="w-4 h-4 text-slate-400"/> {building.accounts.reduce((sum, acc) => {
const count = acc.registered?.length || acc.registeredCount || 0;
return sum + (typeof count === 'number' ? count : 0);
}, 0)}
</p>
</div>
<div className="text-center px-2 cursor-pointer hover:bg-slate-50 transition-colors rounded" onClick={() => onNavigate('finance')}>
<p className="text-slate-400 text-xs font-bold uppercase">Баланс</p>
<p className={`text-xl font-black flex items-center justify-center gap-1 ${building.financials.balance >= 0 ? 'text-emerald-600' : 'text-red-600'}`}>
<Wallet className="w-4 h-4 text-slate-400"/> {(building.financials.balance / 1000).toFixed(0)}k
</p>
</div>
<div className="text-center px-2 cursor-pointer hover:bg-slate-50 transition-colors rounded" onClick={() => onNavigate('inspections')}>
<p className="text-slate-400 text-xs font-bold uppercase">Состояние</p>
<p className="text-xl font-black text-slate-800 flex items-center justify-center gap-1">
<Activity className="w-4 h-4 text-amber-500"/> Удовл.
</p>
</div>
<div className="text-center px-2 cursor-pointer hover:bg-slate-50 transition-colors rounded" onClick={() => onNavigate('passport')}>
<p className="text-slate-400 text-xs font-bold uppercase">Износ</p>
<p className="text-xl font-black text-slate-800 flex items-center justify-center gap-1">
<TrendingDown className="w-4 h-4 text-slate-400"/> 35%
</p>
</div>
</div>
</div>
{/* AI Audit Section */}
<div className="bg-gradient-to-br from-indigo-600 to-violet-700 rounded-2xl shadow-xl text-white p-6 relative overflow-hidden">
<div className="absolute top-0 right-0 p-8 opacity-10">
<Bot className="w-32 h-32" />
</div>
<h3 className="text-lg font-bold flex items-center gap-2 mb-2"><Sparkles className="w-5 h-5 text-yellow-300"/> Анализ Дома</h3>
<p className="text-indigo-100 text-sm mb-4 max-w-lg">Краткая сводка состояния дома, финансовых показателей и рекомендаций по эксплуатации на основе данных паспорта и истории осмотров.</p>
{!auditResult ? (
<button
onClick={handleAudit}
disabled={loadingAudit}
className="bg-white text-indigo-600 px-4 py-2 rounded-xl font-bold text-sm shadow-lg hover:bg-indigo-50 transition-transform active:scale-95 flex items-center gap-2 disabled:opacity-70"
>
{loadingAudit ? 'Анализирую...' : 'Сгенерировать отчет'}
</button>
) : (
<div className="bg-white/10 backdrop-blur-md rounded-xl p-4 text-sm animate-fade-in border border-white/20">
<div className="prose prose-invert prose-sm max-w-none">
<pre className="whitespace-pre-wrap font-sans">{auditResult}</pre>
</div>
<button onClick={() => setAuditResult(null)} className="mt-3 text-xs text-indigo-200 hover:text-white underline">Скрыть отчет</button>
</div>
)}
</div>
{/* Tasks Widget */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm">
<h3 className="font-bold text-slate-700 mb-4 flex items-center gap-2"><CheckCircle2 className="w-5 h-5 text-primary-500"/> Текущие задачи</h3>
<div className="space-y-3">
{building.tasks.map(task => (
<div key={task.id} className="flex items-start gap-3 p-2 hover:bg-slate-50 rounded-lg transition-colors cursor-pointer">
<div className={`mt-1 w-4 h-4 rounded-full border-2 ${task.status === 'done' ? 'bg-emerald-500 border-emerald-500' : 'border-slate-300'}`}></div>
<div className="flex-1">
<p className={`text-sm font-medium ${task.status === 'done' ? 'text-slate-400 line-through' : 'text-slate-800'}`}>{task.title}</p>
<p className="text-xs text-slate-500 flex items-center gap-2 mt-1">
<Calendar className="w-3 h-3"/> {task.deadline}
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold uppercase ${task.priority === 'high' ? 'bg-red-100 text-red-600' : 'bg-slate-100 text-slate-500'}`}>{task.priority}</span>
</p>
</div>
</div>
))}
<button className="w-full py-2 text-xs font-bold text-primary-600 border border-dashed border-primary-200 rounded-lg hover:bg-primary-50 mt-2">
+ Добавить задачу
</button>
</div>
</div>
<div className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm">
<h3 className="font-bold text-slate-700 mb-4 flex items-center gap-2"><Calendar className="w-5 h-5 text-primary-500"/> План работ (Май)</h3>
<div className="space-y-4">
{building.annualPlan.map(plan => (
<div key={plan.id}>
<div className="flex justify-between text-xs mb-1">
<span className="font-bold text-slate-700">{plan.workName}</span>
<span className="text-slate-500">{plan.progress}%</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2">
<div className={`h-2 rounded-full ${plan.progress === 100 ? 'bg-emerald-500' : 'bg-primary-500'}`} style={{ width: `${plan.progress}%` }}></div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { Building } from '../../types';
import { PassportView } from './PassportView';
import { ResidentsView } from './ResidentsView';
export const Passport: React.FC<{
building: Building,
isEditing: boolean,
setBuilding: React.Dispatch<React.SetStateAction<Building>>
}> = ({ building, isEditing, setBuilding }) => {
const updatePassport = (section: keyof Building['passport'], field: string, value: any) => {
setBuilding(prev => {
try {
const sectionData = prev.passport[section] as any;
// Если это массив (lifts, meters), заменяем весь массив
if (Array.isArray(sectionData)) {
return {
...prev,
passport: {
...prev.passport,
[section]: value
}
};
}
// Иначе обновляем поле в объекте
// Проверяем, что sectionData существует, иначе создаем пустой объект
const currentSectionData = sectionData || {};
return {
...prev,
passport: {
...prev.passport,
[section]: {
...currentSectionData,
[field]: value
}
}
};
} catch (error) {
console.error('Error in updatePassport:', error, { section, field, value });
return prev; // Возвращаем предыдущее состояние при ошибке
}
});
};
const updatePassportArray = (section: 'meters' | 'lifts', index: number, field: string, value: any) => {
setBuilding(prev => {
const newArray = [...prev.passport[section]];
// @ts-ignore
newArray[index] = { ...newArray[index], [field]: value };
return {
...prev,
passport: {
...prev.passport,
[section]: newArray
}
};
});
};
return (
<div className="space-y-8 animate-fade-in">
{/* Technical Passport Sheet */}
<PassportView
building={building}
isEditing={isEditing}
updatePassport={updatePassport}
updatePassportArray={updatePassportArray}
/>
{/* Resident Asset Sheet (Merged into Passport as per 8-tab logic) */}
<div className="pt-6 border-t border-slate-200">
<h3 className="font-black text-slate-800 text-[10px] uppercase tracking-[0.2em] mb-6 px-1">
Совет дома и Контакты
</h3>
<ResidentsView building={building} setBuilding={setBuilding} isEditing={isEditing} />
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,630 @@
import React, { useMemo, useState } from 'react';
import { Building, PersonalAccount } from '../../types';
import { Smile, Frown, Meh, MessageCircle, X, Check, UserCheck, Search, User, Phone, Edit2, Save } from 'lucide-react';
export const ResidentsView: React.FC<{ building: Building, setBuilding: React.Dispatch<React.SetStateAction<Building>>, isEditing: boolean }> = ({ building, setBuilding, isEditing }) => {
const accounts = building.accounts || [];
const [showSelectModal, setShowSelectModal] = useState(false);
const [selectedContacts, setSelectedContacts] = useState<Map<string, 'chairman' | 'activist' | 'resident'>>(new Map());
const [searchTerm, setSearchTerm] = useState('');
const [selectedResident, setSelectedResident] = useState<{ id: string; name: string; apartment: string; phone?: string; accountId?: string; personType?: 'owner' | 'registered'; personIndex?: number } | null>(null);
const [isEditingResident, setIsEditingResident] = useState(false);
const [editFormData, setEditFormData] = useState({ fullName: '', phone: '' });
const contactsFromAccounts = useMemo(() => {
const res: Array<{ name: string; apartment: string; phone?: string; mood: 'happy' | 'angry' | 'neutral'; accountId?: string; personType?: 'owner' | 'registered'; personIndex?: number }> = [];
const sentimentToMood = (sentiment?: string): 'happy' | 'angry' | 'neutral' => {
if (!sentiment) return 'neutral';
if (sentiment === 'positive' || sentiment === 'loyal') return 'happy';
if (sentiment === 'negative' || sentiment === 'toxic') return 'angry';
return 'neutral';
};
for (const acc of accounts) {
const apt = acc.apartmentNumber || '';
// Owners
for (let i = 0; i < (acc.owners || []).length; i++) {
const owner = acc.owners[i];
if (!owner?.fullName) continue;
res.push({
name: owner.fullName,
apartment: apt,
phone: owner.phone,
mood: sentimentToMood(owner.residentProfile?.sentiment),
accountId: acc.id,
personType: 'owner',
personIndex: i
});
}
// Registered
for (let i = 0; i < (acc.registered || []).length; i++) {
const reg = acc.registered[i];
if (!reg?.fullName) continue;
res.push({
name: reg.fullName,
apartment: apt,
phone: reg.phone,
mood: sentimentToMood(reg.residentProfile?.sentiment),
accountId: acc.id,
personType: 'registered',
personIndex: i
});
}
}
// unique by name+apartment (берем первый найденный)
const uniq = new Map<string, { name: string; apartment: string; phone?: string; mood: 'happy' | 'angry' | 'neutral'; accountId?: string; personType?: 'owner' | 'registered'; personIndex?: number }>();
for (const c of res) {
const key = `${c.apartment}::${c.name}`.toLowerCase();
if (!uniq.has(key)) uniq.set(key, c);
}
return Array.from(uniq.values());
}, [accounts]);
// Получаем список жителей, которых еще нет в списке
const availableContacts = useMemo(() => {
const existing = building.residents || [];
const existingKeys = new Set(existing.map(r => `${r.apartment}::${r.name}`.toLowerCase()));
return contactsFromAccounts.filter(c => c.name && !existingKeys.has(`${c.apartment}::${c.name}`.toLowerCase()));
}, [contactsFromAccounts, building.residents]);
// Фильтруем контакты по поисковому запросу
const filteredAvailableContacts = useMemo(() => {
if (!searchTerm.trim()) return availableContacts;
const term = searchTerm.toLowerCase();
return availableContacts.filter(c =>
c.name.toLowerCase().includes(term) ||
c.apartment.toLowerCase().includes(term)
);
}, [availableContacts, searchTerm]);
// Находим данные жителя из лицевых счетов (для получения телефона)
const findResidentData = (residentName: string, apartment: string): { phone?: string; accountId?: string; personType?: 'owner' | 'registered'; personIndex?: number } => {
// Сначала ищем в contactsFromAccounts (быстрее)
const contact = contactsFromAccounts.find(c =>
c.name === residentName && c.apartment === apartment
);
if (contact) {
return {
phone: contact.phone,
accountId: contact.accountId,
personType: contact.personType,
personIndex: contact.personIndex
};
}
// Если не нашли, ищем напрямую в accounts
for (const acc of accounts) {
if (acc.apartmentNumber !== apartment) continue;
// Проверяем собственников
for (let i = 0; i < (acc.owners || []).length; i++) {
const owner = acc.owners[i];
if (owner?.fullName === residentName) {
return {
phone: owner.phone,
accountId: acc.id,
personType: 'owner',
personIndex: i
};
}
}
// Проверяем прописанных
for (let i = 0; i < (acc.registered || []).length; i++) {
const reg = acc.registered[i];
if (reg?.fullName === residentName) {
return {
phone: reg.phone,
accountId: acc.id,
personType: 'registered',
personIndex: i
};
}
}
}
return {};
};
const handleOpenSelectModal = () => {
setSelectedContacts(new Map());
setShowSelectModal(true);
};
const toggleContactSelection = (key: string) => {
setSelectedContacts(prev => {
const newMap = new Map(prev);
if (newMap.has(key)) {
newMap.delete(key);
} else {
newMap.set(key, 'resident'); // По умолчанию житель
}
return newMap;
});
};
const setContactRole = (key: string, role: 'chairman' | 'activist' | 'resident') => {
setSelectedContacts(prev => {
const newMap = new Map(prev);
if (newMap.has(key)) {
newMap.set(key, role);
}
return newMap;
});
};
const handleAddSelectedContacts = () => {
if (selectedContacts.size === 0) {
alert('Выберите хотя бы одного жителя');
return;
}
setBuilding((prev) => {
const existing = prev.residents || [];
const existingKeys = new Set(existing.map(r => `${r.apartment}::${r.name}`.toLowerCase()));
const toAdd = Array.from(selectedContacts.entries())
.map(([key, role], index) => {
const contact = availableContacts.find(c =>
`${c.apartment}::${c.name}`.toLowerCase() === key
);
if (!contact || existingKeys.has(key)) return null;
return {
id: `acc-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`,
name: contact.name,
role: role,
apartment: contact.apartment,
mood: contact.mood,
lastContact: '—',
};
})
.filter((item): item is NonNullable<typeof item> => item !== null);
if (toAdd.length === 0) return prev;
setShowSelectModal(false);
setSelectedContacts(new Map());
return { ...prev, residents: [...toAdd, ...existing], isDirty: true };
});
};
return (
<div className="space-y-6 animate-fade-in">
<div className="flex justify-between items-center">
<h3 className="font-bold text-slate-700 text-sm uppercase">Актив дома (Жители)</h3>
<span className="text-xs bg-slate-100 px-2 py-1 rounded font-bold text-slate-500">NPS: {building.nps}</span>
</div>
<div className="grid grid-cols-1 gap-3">
{building.residents.map(r => {
const residentData = findResidentData(r.name, r.apartment);
return (
<div
key={r.id}
className="bg-white p-4 rounded-xl border border-slate-200 shadow-sm flex items-center justify-between cursor-pointer hover:border-primary-300 transition-colors"
onClick={() => {
const data = findResidentData(r.name, r.apartment);
setSelectedResident({
id: r.id,
name: r.name,
apartment: r.apartment,
phone: data.phone,
accountId: data.accountId,
personType: data.personType,
personIndex: data.personIndex
});
setEditFormData({ fullName: r.name, phone: data.phone || '' });
setIsEditingResident(false);
}}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white font-bold ${r.mood === 'happy' ? 'bg-emerald-400' : r.mood === 'angry' ? 'bg-red-400' : 'bg-slate-400'}`}>
{r.mood === 'happy' ? <Smile className="w-6 h-6"/> : r.mood === 'angry' ? <Frown className="w-6 h-6"/> : <Meh className="w-6 h-6"/>}
</div>
<div className="flex-1">
<h4 className="font-bold text-slate-800 text-sm">{r.name}</h4>
<div className="flex items-center gap-2 mt-1">
<p className="text-xs text-slate-500">
Кв. {r.apartment}
</p>
{isEditing ? (
<select
value={r.role}
onChange={(e) => {
setBuilding(prev => ({
...prev,
residents: prev.residents.map(res =>
res.id === r.id
? { ...res, role: e.target.value as 'chairman' | 'activist' | 'resident' }
: res
),
isDirty: true
}));
}}
onClick={(e) => e.stopPropagation()}
className="text-xs font-bold px-2 py-0.5 rounded border border-slate-300 bg-white focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="resident">Житель</option>
<option value="activist">Активист</option>
<option value="chairman">Председатель</option>
</select>
) : (
<p className="text-xs text-slate-500">
{r.role === 'chairman' ? 'Председатель' : r.role === 'activist' ? 'Активист' : 'Житель'}
</p>
)}
</div>
</div>
</div>
<div className="text-right flex items-center gap-2">
{isEditing && (
<button
onClick={(e) => {
e.stopPropagation();
if (confirm('Удалить этого жителя из списка?')) {
setBuilding(prev => ({
...prev,
residents: prev.residents.filter(res => res.id !== r.id),
isDirty: true
}));
}
}}
className="p-2 bg-red-50 rounded-full text-red-400 hover:text-red-600 hover:bg-red-100 transition-colors"
>
<X className="w-4 h-4"/>
</button>
)}
<button
onClick={(e) => e.stopPropagation()}
className="p-2 bg-slate-50 rounded-full text-slate-400 hover:text-primary-600 hover:bg-primary-50 transition-colors"
>
<MessageCircle className="w-5 h-5"/>
</button>
</div>
</div>
);
})}
<button
type="button"
onClick={handleOpenSelectModal}
disabled={availableContacts.length === 0}
className="w-full py-3 border border-dashed border-slate-300 rounded-xl text-slate-400 text-sm font-bold hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
+ Добавить контакты из лицевых счетов
</button>
</div>
{/* Модальное окно выбора жителей */}
{showSelectModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={() => setShowSelectModal(false)}>
<div className="bg-white rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden shadow-2xl animate-slide-up flex flex-col" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-200 flex justify-between items-center">
<div>
<h3 className="text-lg font-bold text-slate-800">Выберите жителей для совета дома</h3>
<p className="text-sm text-slate-500 mt-1">Отметьте жителей и назначьте им роли</p>
</div>
<button onClick={() => {
setShowSelectModal(false);
setSearchTerm('');
}} className="p-2 hover:bg-slate-100 rounded-full">
<X className="w-5 h-5 text-slate-400"/>
</button>
</div>
{/* Поиск */}
<div className="px-6 pt-4 pb-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Поиск по имени или квартире..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm bg-white"
/>
</div>
</div>
<div className="flex-1 overflow-auto p-6">
{availableContacts.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<UserCheck className="w-12 h-12 mx-auto mb-3 opacity-20"/>
<p>Все жители уже добавлены</p>
</div>
) : filteredAvailableContacts.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<Search className="w-12 h-12 mx-auto mb-3 opacity-20"/>
<p>Ничего не найдено</p>
</div>
) : (
<div className="space-y-2">
{filteredAvailableContacts.map((contact) => {
const contactData = findResidentData(contact.name, contact.apartment);
const key = `${contact.apartment}::${contact.name}`.toLowerCase();
const isSelected = selectedContacts.has(key);
const role = selectedContacts.get(key) || 'resident';
return (
<div
key={key}
className={`p-4 border-2 rounded-xl transition-all ${
isSelected
? 'border-primary-500 bg-primary-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<div
onClick={() => toggleContactSelection(key)}
className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 mt-0.5 cursor-pointer ${
isSelected
? 'bg-primary-500 border-primary-500'
: 'border-slate-300'
}`}
>
{isSelected && (
<Check className="w-3 h-3 text-white"/>
)}
</div>
<div className="flex items-center gap-3 flex-1">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white font-bold ${
contact.mood === 'happy' ? 'bg-emerald-400' : contact.mood === 'angry' ? 'bg-red-400' : 'bg-slate-400'
}`}>
{contact.mood === 'happy' ? <Smile className="w-6 h-6"/> : contact.mood === 'angry' ? <Frown className="w-6 h-6"/> : <Meh className="w-6 h-6"/>}
</div>
<div className="flex-1">
<div className="font-bold text-slate-800">{contact.name}</div>
<div className="text-sm text-slate-500">Кв. {contact.apartment}</div>
{contactData.phone && (
<div className="text-xs text-slate-400 flex items-center gap-1 mt-0.5">
<Phone className="w-3 h-3"/>
{contactData.phone}
</div>
)}
</div>
</div>
</div>
{isSelected && (
<div className="flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<label className="text-xs text-slate-500 font-bold whitespace-nowrap">Роль:</label>
<select
value={role}
onChange={(e) => setContactRole(key, e.target.value as 'chairman' | 'activist' | 'resident')}
onClick={(e) => e.stopPropagation()}
className="px-3 py-1.5 border border-slate-300 rounded-lg text-sm font-bold text-slate-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="resident">Житель</option>
<option value="activist">Активист</option>
<option value="chairman">Председатель</option>
</select>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{selectedContacts.size > 0 && (
<div className="p-6 border-t border-slate-200 bg-slate-50 flex justify-between items-center">
<div className="text-sm text-slate-600">
Выбрано жителей: <span className="font-bold text-slate-800">{selectedContacts.size}</span>
</div>
<div className="flex gap-3">
<button
onClick={() => {
setShowSelectModal(false);
setSelectedContacts(new Map());
setSearchTerm('');
}}
className="px-4 py-2 text-slate-600 font-bold bg-white border border-slate-200 rounded-xl hover:bg-slate-50"
>
Отмена
</button>
<button
onClick={handleAddSelectedContacts}
className="px-4 py-2 bg-primary-600 text-white font-bold rounded-xl hover:bg-primary-700 flex items-center gap-2"
>
<UserCheck className="w-4 h-4"/> Добавить выбранных
</button>
</div>
</div>
)}
</div>
</div>
)}
{/* Модальное окно карточки жителя */}
{selectedResident && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in" onClick={() => {
setSelectedResident(null);
setIsEditingResident(false);
}}>
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl animate-slide-up" onClick={e => e.stopPropagation()}>
<div className="p-6 border-b border-slate-200 flex justify-between items-center">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-primary-100 flex items-center justify-center">
<User className="w-6 h-6 text-primary-600"/>
</div>
<div>
<h3 className="text-lg font-bold text-slate-800">Карточка жителя</h3>
<p className="text-xs text-slate-500">Кв. {selectedResident.apartment}</p>
</div>
</div>
<button
onClick={() => {
setSelectedResident(null);
setIsEditingResident(false);
}}
className="p-2 hover:bg-slate-100 rounded-full"
>
<X className="w-5 h-5 text-slate-400"/>
</button>
</div>
<div className="p-6 space-y-4">
{isEditingResident ? (
<>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-2">
ФИО *
</label>
<input
type="text"
value={editFormData.fullName}
onChange={(e) => setEditFormData({ ...editFormData, fullName: e.target.value })}
className="w-full p-3 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
placeholder="Иванов Иван Иванович"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-2">
Телефон
</label>
<input
type="tel"
value={editFormData.phone}
onChange={(e) => setEditFormData({ ...editFormData, phone: e.target.value })}
className="w-full p-3 rounded-xl border border-slate-200 text-sm focus:ring-2 focus:ring-primary-500 outline-none"
placeholder="+7 (999) 123-45-67"
/>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={() => {
// Сохраняем изменения
if (!editFormData.fullName.trim()) {
alert('ФИО обязательно для заполнения');
return;
}
// Обновляем данные в accounts
if (selectedResident.accountId && selectedResident.personType && selectedResident.personIndex !== undefined) {
const account = accounts.find(a => a.id === selectedResident.accountId);
if (account) {
if (selectedResident.personType === 'owner') {
const updatedOwners = [...account.owners];
updatedOwners[selectedResident.personIndex] = {
...updatedOwners[selectedResident.personIndex],
fullName: editFormData.fullName,
phone: editFormData.phone
};
setBuilding(prev => ({
...prev,
accounts: prev.accounts.map(acc =>
acc.id === account.id
? { ...acc, owners: updatedOwners }
: acc
),
residents: prev.residents.map(res =>
res.id === selectedResident.id
? { ...res, name: editFormData.fullName }
: res
),
isDirty: true
}));
} else if (selectedResident.personType === 'registered') {
const updatedRegistered = [...(account.registered || [])];
updatedRegistered[selectedResident.personIndex] = {
...updatedRegistered[selectedResident.personIndex],
fullName: editFormData.fullName,
phone: editFormData.phone
};
setBuilding(prev => ({
...prev,
accounts: prev.accounts.map(acc =>
acc.id === account.id
? { ...acc, registered: updatedRegistered }
: acc
),
residents: prev.residents.map(res =>
res.id === selectedResident.id
? { ...res, name: editFormData.fullName }
: res
),
isDirty: true
}));
}
}
} else {
// Если не найдено в accounts, просто обновляем residents
setBuilding(prev => ({
...prev,
residents: prev.residents.map(res =>
res.id === selectedResident.id
? { ...res, name: editFormData.fullName }
: res
),
isDirty: true
}));
}
setSelectedResident({
...selectedResident,
name: editFormData.fullName,
phone: editFormData.phone
});
setIsEditingResident(false);
}}
className="flex-1 bg-primary-600 text-white px-4 py-2.5 rounded-xl font-bold text-sm hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
>
<Save className="w-4 h-4"/> Сохранить
</button>
<button
onClick={() => {
setIsEditingResident(false);
setEditFormData({ fullName: selectedResident.name, phone: selectedResident.phone || '' });
}}
className="px-4 py-2.5 bg-slate-100 text-slate-600 rounded-xl font-bold text-sm hover:bg-slate-200 transition-colors"
>
Отмена
</button>
</div>
</>
) : (
<>
<div className="space-y-3">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">
ФИО
</label>
<p className="text-sm font-bold text-slate-800">{selectedResident.name}</p>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1 flex items-center gap-1">
<Phone className="w-3 h-3"/>
Телефон
</label>
<p className="text-sm text-slate-700">{selectedResident.phone || 'Не указан'}</p>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase mb-1">
Квартира
</label>
<p className="text-sm text-slate-700">Кв. {selectedResident.apartment}</p>
</div>
</div>
{isEditing && (
<button
onClick={() => setIsEditingResident(true)}
className="w-full mt-4 bg-primary-600 text-white px-4 py-2.5 rounded-xl font-bold text-sm hover:bg-primary-700 transition-colors flex items-center justify-center gap-2"
>
<Edit2 className="w-4 h-4"/> Редактировать
</button>
)}
</>
)}
</div>
</div>
</div>
)}
</div>
);
};

3
components/building/Supply.tsx Executable file
View File

@@ -0,0 +1,3 @@
import { InventoryView as Supply } from './InventoryView';
export { Supply };

716
components/building/TaskModal.tsx Executable file
View File

@@ -0,0 +1,716 @@
import React, { useState, useEffect } from 'react';
import { BuildingTask, Employee, TaskComment, WorkPhoto } from '../../types';
import { X, User, Calendar, Flag, Tag, Clock, MessageSquare, ImageIcon, Camera } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
import { apiClient } from '../../services/apiClient';
interface TaskModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (task: BuildingTask) => void;
task?: BuildingTask | null;
buildingId: string;
employees?: Employee[];
}
export const TaskModal: React.FC<TaskModalProps> = ({
isOpen,
onClose,
onSave,
task,
buildingId,
employees = []
}) => {
const [formData, setFormData] = useState<Partial<BuildingTask>>({
title: '',
description: '',
deadline: '',
status: 'new',
priority: 'medium',
assignedTo: '',
assignedToName: '',
createdBy: '',
createdByName: '',
category: '',
tags: [],
estimatedHours: undefined,
comments: [],
requirePhotoReport: true,
photoReportId: undefined,
});
const [availableEmployees, setAvailableEmployees] = useState<Employee[]>([]);
const [tagInput, setTagInput] = useState('');
const [commentText, setCommentText] = useState('');
const [photoReport, setPhotoReport] = useState<WorkPhoto | null>(null);
const [photoReportLoading, setPhotoReportLoading] = useState(false);
const [showPhotoForm, setShowPhotoForm] = useState(false);
const [draftTaskId, setDraftTaskId] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
if (task) {
setFormData({
title: task.title || '',
description: task.description || '',
deadline: task.deadline || '',
status: task.status || 'new',
priority: task.priority || 'medium',
assignedTo: task.assignedTo || '',
assignedToName: task.assignedToName || '',
createdBy: task.createdBy || '',
createdByName: task.createdByName || '',
category: task.category || '',
tags: task.tags || [],
estimatedHours: task.estimatedHours,
comments: task.comments || [],
requirePhotoReport: task.requirePhotoReport !== false,
photoReportId: task.photoReportId,
});
setDraftTaskId(task.id);
setPhotoReport(null);
if (task.photoReportId) {
setPhotoReportLoading(true);
apiClient.get<WorkPhoto>(`/pr/work-photos/${task.photoReportId}`)
.then((data: WorkPhoto) => setPhotoReport(data))
.catch(() => setPhotoReport(null))
.finally(() => setPhotoReportLoading(false));
} else {
setPhotoReportLoading(false);
}
} else {
const newId = `task-${Date.now()}`;
setDraftTaskId(newId);
setFormData({
title: '',
description: '',
deadline: '',
status: 'new',
priority: 'medium',
assignedTo: '',
assignedToName: '',
createdBy: '',
createdByName: '',
category: '',
tags: [],
estimatedHours: undefined,
comments: [],
requirePhotoReport: true,
photoReportId: undefined,
});
setPhotoReport(null);
}
setShowPhotoForm(false);
setCommentText('');
fetchEmployees();
}
}, [isOpen, task, buildingId]);
const fetchEmployees = async () => {
try {
const fetchedEmployees = await backendApi.getEmployees();
setAvailableEmployees(fetchedEmployees);
} catch (error) {
console.error('Failed to fetch employees:', error);
setAvailableEmployees(employees);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.title?.trim()) return;
const selectedEmployee = availableEmployees.find(emp => emp.id === formData.assignedTo);
const authorEmployee = availableEmployees.find(emp => emp.id === formData.createdBy);
const taskId = task?.id || draftTaskId || `task-${Date.now()}`;
const taskData: BuildingTask = {
id: taskId,
title: formData.title.trim(),
description: formData.description?.trim(),
deadline: formData.deadline || new Date().toISOString(),
status: formData.status || 'new',
priority: formData.priority || 'medium',
assignedTo: formData.assignedTo || undefined,
assignedToName: selectedEmployee?.name || formData.assignedToName || undefined,
createdBy: formData.createdBy || undefined,
createdByName: authorEmployee?.name || formData.createdByName || undefined,
category: formData.category || undefined,
tags: formData.tags || [],
estimatedHours: formData.estimatedHours,
createdAt: task?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
buildingId: buildingId,
comments: formData.comments || [],
requirePhotoReport: formData.requirePhotoReport !== false,
photoReportId: formData.photoReportId,
};
onSave(taskData);
onClose();
};
const handleAddComment = () => {
if (!commentText.trim()) return;
const author = availableEmployees.find(emp => emp.id === formData.createdBy)?.name || formData.createdByName || 'Не указан';
const newComment: TaskComment = {
id: `comment-${Date.now()}`,
authorId: formData.createdBy || undefined,
authorName: author,
text: commentText.trim(),
createdAt: new Date().toISOString(),
};
setFormData({
...formData,
comments: [...(formData.comments || []), newComment],
});
setCommentText('');
};
const handlePhotoReportCreated = (workPhoto: WorkPhoto) => {
const updatedFormData = { ...formData, photoReportId: workPhoto.id };
setFormData(updatedFormData);
setPhotoReport(workPhoto);
setShowPhotoForm(false);
// Сразу сохраняем задачу с привязкой к фото, чтобы photoReportId не терялся
const taskId = task?.id || draftTaskId || `task-${Date.now()}`;
const authorEmployee = availableEmployees.find(emp => emp.id === formData.createdBy);
const selectedEmployee = availableEmployees.find(emp => emp.id === formData.assignedTo);
const taskData: BuildingTask = {
id: taskId,
title: (formData.title || '').trim() || 'Задача',
description: formData.description?.trim(),
deadline: formData.deadline || new Date().toISOString(),
status: formData.status || 'new',
priority: formData.priority || 'medium',
assignedTo: formData.assignedTo || undefined,
assignedToName: selectedEmployee?.name || formData.assignedToName || undefined,
createdBy: formData.createdBy || undefined,
createdByName: authorEmployee?.name || formData.createdByName || undefined,
category: formData.category || undefined,
tags: formData.tags || [],
estimatedHours: formData.estimatedHours,
createdAt: task?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
buildingId: buildingId,
comments: formData.comments || [],
requirePhotoReport: formData.requirePhotoReport !== false,
photoReportId: workPhoto.id,
};
onSave(taskData);
};
const handleAddTag = () => {
if (tagInput.trim() && !formData.tags?.includes(tagInput.trim())) {
setFormData({
...formData,
tags: [...(formData.tags || []), tagInput.trim()]
});
setTagInput('');
}
};
const handleRemoveTag = (tag: string) => {
setFormData({
...formData,
tags: formData.tags?.filter(t => t !== tag) || []
});
};
const priorityColors = {
low: 'bg-slate-100 text-slate-600',
medium: 'bg-blue-50 text-blue-600',
high: 'bg-amber-50 text-amber-600',
urgent: 'bg-red-50 text-red-600',
};
const statusColors = {
new: 'bg-blue-50 text-blue-600',
in_progress: 'bg-amber-50 text-amber-600',
done: 'bg-emerald-50 text-emerald-600',
cancelled: 'bg-slate-100 text-slate-600',
};
const uploadsBase = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api').replace(/\/api\/?$/, '') || 'http://localhost:4000';
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-white border-b border-slate-200 p-6 flex items-center justify-between">
<h2 className="text-xl font-bold text-slate-800">
{task ? 'Редактировать задачу' : 'Новая задача'}
</h2>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Название */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Название задачи *
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
required
/>
</div>
{/* Описание */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Описание
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none resize-none"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Срок */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Calendar className="w-4 h-4" /> Срок выполнения *
</label>
<input
type="date"
value={formData.deadline ? new Date(formData.deadline).toISOString().split('T')[0] : ''}
onChange={(e) => setFormData({ ...formData, deadline: new Date(e.target.value).toISOString() })}
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
required
/>
</div>
{/* Приоритет */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Flag className="w-4 h-4" /> Приоритет
</label>
<select
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value as BuildingTask['priority'] })}
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="low">Низкий</option>
<option value="medium">Средний</option>
<option value="high">Высокий</option>
<option value="urgent">Срочный</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Ответственный */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<User className="w-4 h-4" /> Ответственный
</label>
<select
value={formData.assignedTo}
onChange={(e) => {
const selected = availableEmployees.find(emp => emp.id === e.target.value);
setFormData({
...formData,
assignedTo: e.target.value,
assignedToName: selected?.name || ''
});
}}
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="">Не назначен</option>
{availableEmployees.filter(emp => emp.status === 'active').map(emp => (
<option key={emp.id} value={emp.id}>
{emp.name} - {emp.position}
</option>
))}
</select>
</div>
{/* Постановщик */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<User className="w-4 h-4" /> Постановщик
</label>
<select
value={formData.createdBy}
onChange={(e) => {
const selected = availableEmployees.find(emp => emp.id === e.target.value);
setFormData({
...formData,
createdBy: e.target.value,
createdByName: selected?.name || ''
});
}}
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="">Не указан</option>
{availableEmployees.filter(emp => emp.status === 'active').map(emp => (
<option key={emp.id} value={emp.id}>
{emp.name} - {emp.position}
</option>
))}
</select>
</div>
{/* Статус */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Статус
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as BuildingTask['status'] })}
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
>
<option value="new">Новая</option>
<option value="in_progress">В работе</option>
<option value="done">Выполнена</option>
<option value="cancelled">Отменена</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Категория */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Tag className="w-4 h-4" /> Категория
</label>
<input
type="text"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
placeholder="Например: Ремонт, Обслуживание"
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
{/* Оценка времени */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<Clock className="w-4 h-4" /> Оценка времени (часы)
</label>
<input
type="number"
min="0"
step="0.5"
value={formData.estimatedHours || ''}
onChange={(e) => setFormData({ ...formData, estimatedHours: e.target.value ? parseFloat(e.target.value) : undefined })}
className="w-full border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
/>
</div>
</div>
{/* Комментарии */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
<MessageSquare className="w-4 h-4" /> Комментарии
</label>
{(formData.comments && formData.comments.length > 0) && (
<ul className="space-y-2 mb-3 max-h-32 overflow-y-auto rounded-xl border border-slate-100 p-3 bg-slate-50">
{formData.comments.map(c => (
<li key={c.id} className="text-sm text-slate-700">
<span className="font-bold text-slate-500">{c.authorName}:</span> {c.text}
</li>
))}
</ul>
)}
<div className="flex gap-2">
<input
type="text"
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddComment())}
placeholder="Добавить комментарий..."
className="flex-1 border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none text-sm"
/>
<button
type="button"
onClick={handleAddComment}
className="px-4 py-2 bg-primary-600 text-white rounded-xl font-bold hover:bg-primary-700 transition-colors text-sm"
>
Добавить
</button>
</div>
</div>
{/* Фото отчёт до/после */}
<div className="border border-slate-200 rounded-xl p-4 bg-slate-50/50">
<label className="flex items-center gap-2 cursor-pointer mb-3">
<input
type="checkbox"
checked={formData.requirePhotoReport !== false}
onChange={(e) => setFormData({ ...formData, requirePhotoReport: e.target.checked })}
className="rounded border-slate-300"
/>
<span className="text-sm font-bold text-slate-700">Требуется фото отчёт по работе (до/после)</span>
</label>
{formData.requirePhotoReport && (
<>
{photoReport || formData.photoReportId ? (
<div>
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<p className="text-xs font-bold text-slate-500 uppercase mb-1">До</p>
{photoReportLoading ? (
<div className="w-full h-24 rounded-lg border border-slate-200 flex items-center justify-center text-slate-400 text-xs">Загрузка...</div>
) : (photoReport?.photoBeforeUrl || (photoReport as any)?.photo_before_url) ? (
<img
src={uploadsBase + (photoReport?.photoBeforeUrl || (photoReport as any)?.photo_before_url)}
alt="До"
className="w-full h-24 object-cover rounded-lg border border-slate-200"
/>
) : (
<div className="w-full h-24 rounded-lg border border-dashed border-slate-200 flex items-center justify-center text-slate-400 text-xs">Нет фото</div>
)}
</div>
<div>
<p className="text-xs font-bold text-slate-500 uppercase mb-1">После</p>
{photoReportLoading ? (
<div className="w-full h-24 rounded-lg border border-slate-200 flex items-center justify-center text-slate-400 text-xs">Загрузка...</div>
) : (photoReport?.photoAfterUrl || (photoReport as any)?.photo_after_url) ? (
<img
src={uploadsBase + (photoReport?.photoAfterUrl || (photoReport as any)?.photo_after_url)}
alt="После"
className="w-full h-24 object-cover rounded-lg border border-slate-200"
/>
) : (
<div className="w-full h-24 rounded-lg border border-dashed border-slate-200 flex items-center justify-center text-slate-400 text-xs">Нет фото</div>
)}
</div>
</div>
<button
type="button"
onClick={() => setShowPhotoForm(true)}
className="text-sm font-bold text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<Camera className="w-4 h-4" /> Изменить фото отчёт
</button>
</div>
) : showPhotoForm ? (
<TaskPhotoReportForm
buildingId={buildingId}
taskId={draftTaskId || undefined}
workName={formData.title || 'Фото отчёт'}
workDescription={formData.description || ''}
onSuccess={handlePhotoReportCreated}
onCancel={() => setShowPhotoForm(false)}
/>
) : (
<button
type="button"
onClick={() => setShowPhotoForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-primary-100 text-primary-700 rounded-xl font-bold hover:bg-primary-200 transition-colors text-sm"
>
<ImageIcon className="w-4 h-4" /> Добавить фото отчёт (до/после)
</button>
)}
</>
)}
</div>
{/* Теги */}
<div>
<label className="block text-sm font-bold text-slate-700 mb-2">
Теги
</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddTag();
}
}}
placeholder="Добавить тег"
className="flex-1 border border-slate-200 rounded-xl px-4 py-2 focus:ring-2 focus:ring-primary-500 outline-none"
/>
<button
type="button"
onClick={handleAddTag}
className="px-4 py-2 bg-primary-600 text-white rounded-xl font-bold hover:bg-primary-700 transition-colors"
>
Добавить
</button>
</div>
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{formData.tags.map(tag => (
<span
key={tag}
className="inline-flex items-center gap-1 px-3 py-1 bg-primary-50 text-primary-700 rounded-lg text-xs font-bold"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="hover:text-primary-900"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
</div>
{/* Кнопки */}
<div className="flex gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-3 border border-slate-200 rounded-xl font-bold text-slate-700 hover:bg-slate-50 transition-colors"
>
Отмена
</button>
<button
type="submit"
className="flex-1 px-4 py-3 bg-primary-600 text-white rounded-xl font-bold hover:bg-primary-700 transition-colors"
>
{task ? 'Сохранить' : 'Создать'}
</button>
</div>
</form>
</div>
</div>
);
};
// Форма добавления фото отчёта до/после для задачи (название и описание из задачи → попадают в справочник PR)
interface TaskPhotoReportFormProps {
buildingId: string;
taskId?: string | null;
workName: string;
workDescription?: string;
onSuccess: (photo: WorkPhoto) => void;
onCancel: () => void;
}
const TaskPhotoReportForm: React.FC<TaskPhotoReportFormProps> = ({
buildingId,
taskId,
workName,
workDescription = '',
onSuccess,
onCancel,
}) => {
const [photoBefore, setPhotoBefore] = useState<File | null>(null);
const [photoAfter, setPhotoAfter] = useState<File | null>(null);
const [description, setDescription] = useState(workDescription);
const [isSubmitting, setIsSubmitting] = useState(false);
// При открытии формы подставляем описание задачи
useEffect(() => {
if (workDescription) setDescription(workDescription);
}, [workDescription]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!photoBefore || !photoAfter) {
alert('Загрузите оба фото: «До» и «После»');
return;
}
try {
setIsSubmitting(true);
const formDataToSend = new FormData();
formDataToSend.append('building_id', buildingId);
if (taskId) formDataToSend.append('task_id', taskId);
formDataToSend.append('work_name', workName || 'Фото отчёт по задаче');
formDataToSend.append('work_date', new Date().toISOString().split('T')[0]);
if (description?.trim()) formDataToSend.append('description', description.trim());
formDataToSend.append('photo_before', photoBefore);
formDataToSend.append('photo_after', photoAfter);
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api';
const res = await fetch(`${API_BASE}/pr/work-photos`, {
method: 'POST',
body: formDataToSend,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = (data && data.error) ? data.error : `Ошибка ${res.status}: ${res.statusText}`;
throw new Error(msg);
}
onSuccess(data as WorkPhoto);
} catch (err: any) {
console.error(err);
const msg = err?.message || 'Не удалось загрузить фото отчёт. Проверьте, что сервер запущен и доступен.';
alert(msg);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="rounded-xl border border-slate-200 p-4 bg-white space-y-3">
<p className="text-sm font-bold text-slate-700">Фото отчёт в формате «До» / «После»</p>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Фото «До» *</label>
<input
type="file"
accept="image/*"
onChange={(e) => setPhotoBefore(e.target.files?.[0] || null)}
className="w-full text-sm border border-slate-200 rounded-lg px-2 py-1.5"
required
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Фото «После» *</label>
<input
type="file"
accept="image/*"
onChange={(e) => setPhotoAfter(e.target.files?.[0] || null)}
className="w-full text-sm border border-slate-200 rounded-lg px-2 py-1.5"
required
/>
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Описание (необязательно)</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full text-sm border border-slate-200 rounded-lg px-2 py-1.5"
placeholder="Кратко, что сделано"
/>
</div>
<div className="flex gap-2">
<button
type="button"
disabled={isSubmitting}
onClick={(e) => { e.preventDefault(); handleSubmit(e as any); }}
className="px-4 py-2 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 disabled:opacity-50"
>
{isSubmitting ? 'Загрузка...' : 'Сохранить фото отчёт'}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 border border-slate-200 rounded-xl text-sm font-bold text-slate-600 hover:bg-slate-50"
>
Отмена
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,3 @@
import { BudgetPlanView as WorkPlan } from './BudgetPlanView';
export { WorkPlan };