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