294 lines
18 KiB
TypeScript
294 lines
18 KiB
TypeScript
|
|
|
|||
|
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|||
|
|
import { X, AlertTriangle, CheckCircle2, TrendingUp, BarChart3 } from 'lucide-react';
|
|||
|
|
import { DevAuditData, InspectionData } from '../../types';
|
|||
|
|
import { backendApi } from '../../services/apiClient';
|
|||
|
|
import {
|
|||
|
|
AUDIT_INSPECTION_SCHEMA,
|
|||
|
|
AUDIT_STATUS_LABELS,
|
|||
|
|
INSPECTION_SCORE_LABELS,
|
|||
|
|
calcCategoryAverage,
|
|||
|
|
calcComplexityIndexFromCategoryAverages,
|
|||
|
|
calcWearPercentFromCategoryAverages,
|
|||
|
|
calculateTariffFromAudit,
|
|||
|
|
formatCategoryOverall,
|
|||
|
|
normalizeRatingToScore,
|
|||
|
|
} from './auditInspectionSchema';
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
auditId: string | null;
|
|||
|
|
onClose: () => void;
|
|||
|
|
onSaved: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const STATUS_OPTIONS: Array<'new' | 'in_progress' | 'completed'> = ['new', 'in_progress', 'completed'];
|
|||
|
|
|
|||
|
|
export const AuditCardModal: React.FC<Props> = ({ auditId, onClose, onSaved }) => {
|
|||
|
|
const [audit, setAudit] = useState<DevAuditData | null>(null);
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
const [saving, setSaving] = useState(false);
|
|||
|
|
const [form, setForm] = useState<Partial<DevAuditData> & { inspectionData?: InspectionData }>({});
|
|||
|
|
|
|||
|
|
const fetchAudit = useCallback(async () => {
|
|||
|
|
if (!auditId) return;
|
|||
|
|
try {
|
|||
|
|
setLoading(true);
|
|||
|
|
const data = await backendApi.getDevelopmentAudit(auditId);
|
|||
|
|
setAudit(data);
|
|||
|
|
setForm({
|
|||
|
|
status: data.status,
|
|||
|
|
wearPercent: data.wearPercent,
|
|||
|
|
projectedMargin: data.projectedMargin,
|
|||
|
|
inspectionData: data.inspectionData ?? {},
|
|||
|
|
});
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error(e);
|
|||
|
|
setAudit(null);
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, [auditId]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
fetchAudit();
|
|||
|
|
}, [fetchAudit]);
|
|||
|
|
|
|||
|
|
const handleSave = async () => {
|
|||
|
|
if (!auditId) return;
|
|||
|
|
try {
|
|||
|
|
setSaving(true);
|
|||
|
|
await backendApi.updateDevelopmentAudit(auditId, {
|
|||
|
|
status: form.status,
|
|||
|
|
wearPercent: computedWearPercent ?? form.wearPercent ?? 0,
|
|||
|
|
projectedMargin: form.projectedMargin,
|
|||
|
|
inspectionData: form.inspectionData ?? undefined,
|
|||
|
|
});
|
|||
|
|
onSaved();
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error(e);
|
|||
|
|
alert('Ошибка сохранения');
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const updateInspection = (categoryKey: string, subKey: string, field: string, value: unknown) => {
|
|||
|
|
setForm(prev => {
|
|||
|
|
const data = { ...(prev.inspectionData || {}) };
|
|||
|
|
const cat = data[categoryKey as keyof InspectionData] || { subItems: [] };
|
|||
|
|
const subItems = [...(cat.subItems || [])];
|
|||
|
|
let item = subItems.find(s => s.key === subKey);
|
|||
|
|
if (!item) {
|
|||
|
|
item = { key: subKey, label: '', rating: null, description: null, noAccess: false, notPresent: false };
|
|||
|
|
subItems.push(item);
|
|||
|
|
}
|
|||
|
|
if (field === 'rating' && value !== null && value !== '') {
|
|||
|
|
const n = typeof value === 'number' ? value : parseInt(String(value), 10);
|
|||
|
|
value = Number.isFinite(n) && n >= 1 && n <= 5 ? n : null;
|
|||
|
|
}
|
|||
|
|
item = { ...item, [field]: value };
|
|||
|
|
const idx = subItems.findIndex(s => s.key === subKey);
|
|||
|
|
if (idx >= 0) subItems[idx] = item; else subItems.push(item);
|
|||
|
|
data[categoryKey as keyof InspectionData] = { ...cat, subItems };
|
|||
|
|
return { ...prev, inspectionData: data };
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getSubItem = (categoryKey: string, subKey: string) => {
|
|||
|
|
const cat = (form.inspectionData || {})[categoryKey as keyof InspectionData];
|
|||
|
|
const item = cat?.subItems?.find(s => s.key === subKey);
|
|||
|
|
return item;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/** Итог по пункту = среднее арифметическое подпунктов (только 1–5, без noAccess/notPresent) */
|
|||
|
|
const getCategoryOverall = (categoryKey: string): number | null => {
|
|||
|
|
const cat = (form.inspectionData || {})[categoryKey as keyof InspectionData];
|
|||
|
|
return calcCategoryAverage(cat?.subItems || []);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/** Износ % и индекс сложности из средних по пунктам осмотра: (5 − среднее) / 4 × 100 */
|
|||
|
|
const categoryAverages = useMemo(() => {
|
|||
|
|
const data = form.inspectionData || {};
|
|||
|
|
return AUDIT_INSPECTION_SCHEMA.map(cat => calcCategoryAverage((data[cat.key] as { subItems?: unknown[] })?.subItems || []));
|
|||
|
|
}, [form.inspectionData]);
|
|||
|
|
const computedWearPercent = useMemo(() => calcWearPercentFromCategoryAverages(categoryAverages), [categoryAverages]);
|
|||
|
|
const computedComplexityIndex = useMemo(() => calcComplexityIndexFromCategoryAverages(categoryAverages), [categoryAverages]);
|
|||
|
|
|
|||
|
|
/** Прогнозный тариф: автоматически при изменении маржи, износа и индекса сложности */
|
|||
|
|
const computedTariff = useMemo(() => {
|
|||
|
|
const wear = computedWearPercent ?? 0;
|
|||
|
|
const complexity = computedComplexityIndex ?? 50;
|
|||
|
|
const margin = form.projectedMargin ?? 15;
|
|||
|
|
return calculateTariffFromAudit({ wearPercent: wear, complexityIndex: complexity, projectedMargin: margin });
|
|||
|
|
}, [computedWearPercent, computedComplexityIndex, form.projectedMargin]);
|
|||
|
|
|
|||
|
|
if (!auditId) return null;
|
|||
|
|
|
|||
|
|
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={onClose}>
|
|||
|
|
<div
|
|||
|
|
className="bg-white rounded-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto shadow-2xl animate-slide-up"
|
|||
|
|
onClick={e => e.stopPropagation()}
|
|||
|
|
>
|
|||
|
|
<div className="sticky top-0 bg-white border-b border-slate-200 px-6 py-4 flex justify-between items-center z-10">
|
|||
|
|
<h2 className="text-lg font-black text-slate-800">{audit?.address ?? 'Аудит'}</h2>
|
|||
|
|
<button type="button" onClick={onClose} className="p-2 hover:bg-slate-100 rounded-xl">
|
|||
|
|
<X className="w-5 h-5 text-slate-400" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{loading ? (
|
|||
|
|
<div className="p-12 text-center text-slate-400">Загрузка...</div>
|
|||
|
|
) : audit ? (
|
|||
|
|
<div className="p-6 space-y-6">
|
|||
|
|
{/* Статус */}
|
|||
|
|
<div>
|
|||
|
|
<label className="text-[10px] text-slate-500 font-bold uppercase block mb-2">Статус аудита</label>
|
|||
|
|
<select
|
|||
|
|
value={form.status ?? 'new'}
|
|||
|
|
onChange={e => setForm(f => ({ ...f, status: e.target.value as DevAuditData['status'] }))}
|
|||
|
|
className="w-full max-w-xs p-2.5 rounded-xl border border-slate-200 text-sm"
|
|||
|
|
>
|
|||
|
|
{STATUS_OPTIONS.map(s => (
|
|||
|
|
<option key={s} value={s}>{AUDIT_STATUS_LABELS[s]}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Износ дома — только из данных пунктов осмотра (средние 1–5 → %), без ручного ввода */}
|
|||
|
|
<div className="grid grid-cols-2 gap-4">
|
|||
|
|
<div className="bg-slate-50 p-4 rounded-3xl border border-slate-100">
|
|||
|
|
<p className="text-[9px] font-black text-slate-400 uppercase mb-1">Износ дома, %</p>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<span className="w-24 text-2xl font-black tabular-nums">
|
|||
|
|
{computedWearPercent != null ? computedWearPercent : '—'}
|
|||
|
|
</span>
|
|||
|
|
<span className="text-2xl font-black text-slate-400">%</span>
|
|||
|
|
{computedWearPercent != null && (computedWearPercent > 50 ? <AlertTriangle className="w-5 h-5 text-red-500" /> : <CheckCircle2 className="w-5 h-5 text-emerald-500" />)}
|
|||
|
|
</div>
|
|||
|
|
<p className="text-[9px] text-slate-500 mt-1">Из средних по пунктам осмотра (1–5)</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="bg-slate-50 p-4 rounded-3xl border border-slate-100">
|
|||
|
|
<p className="text-[9px] font-black text-slate-400 uppercase mb-1">Расчётная маржа, %</p>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<input
|
|||
|
|
type="number"
|
|||
|
|
min={0}
|
|||
|
|
max={100}
|
|||
|
|
step={0.5}
|
|||
|
|
value={form.projectedMargin ?? ''}
|
|||
|
|
onChange={e => setForm(f => ({ ...f, projectedMargin: parseFloat(e.target.value) || 0 }))}
|
|||
|
|
className="w-24 text-2xl font-black border-0 bg-transparent p-0 focus:ring-0 text-primary-600"
|
|||
|
|
/>
|
|||
|
|
<span className="text-2xl font-black text-slate-400">%</span>
|
|||
|
|
<TrendingUp className="w-5 h-5 text-primary-400" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Индекс сложности (из осмотра) и прогнозный тариф (автоматически при изменении маржи, износа, сложности) */}
|
|||
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|||
|
|
<div className="bg-slate-50 p-4 rounded-3xl border border-slate-100">
|
|||
|
|
<p className="text-[9px] font-black text-slate-400 uppercase mb-1">Индекс сложности дома (0–100)</p>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<span className="w-24 text-2xl font-black tabular-nums">
|
|||
|
|
{computedComplexityIndex != null ? computedComplexityIndex : '—'}
|
|||
|
|
</span>
|
|||
|
|
<span className="text-2xl font-black text-slate-400">%</span>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-[9px] text-slate-500 mt-1">Из средних по пунктам осмотра (1–5)</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="bg-slate-900 text-white p-4 rounded-2xl">
|
|||
|
|
<p className="text-[9px] font-black text-slate-400 uppercase mb-1">Прогнозный тариф</p>
|
|||
|
|
<p className="text-xl font-black">{computedTariff} <span className="text-xs font-normal text-slate-400">₽/м²</span></p>
|
|||
|
|
<p className="text-[9px] text-slate-400 mt-1 flex items-center gap-1"><BarChart3 className="w-3.5 h-3.5"/> Меняется при изменении маржи, износа и индекса сложности</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Пункты осмотра */}
|
|||
|
|
<div>
|
|||
|
|
<h3 className="text-sm font-black text-slate-700 uppercase tracking-wider mb-4">Пункты осмотра</h3>
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
{AUDIT_INSPECTION_SCHEMA.map(cat => (
|
|||
|
|
<div key={cat.key} className="border border-slate-200 rounded-2xl p-4 bg-slate-50/50">
|
|||
|
|
<div className="flex justify-between items-center mb-3">
|
|||
|
|
<span className="font-bold text-slate-800">{cat.label}</span>
|
|||
|
|
<span className="text-sm font-bold text-slate-600" title="Среднее арифметическое по подпунктам (только оценки 1–5)">
|
|||
|
|
Среднее: {formatCategoryOverall(getCategoryOverall(cat.key))}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
{cat.subItems.map(sub => {
|
|||
|
|
const item = getSubItem(cat.key, sub.key);
|
|||
|
|
const ratingVal = normalizeRatingToScore(item?.rating) ?? null;
|
|||
|
|
return (
|
|||
|
|
<div key={sub.key} className="bg-white rounded-xl p-3 border border-slate-100">
|
|||
|
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
|||
|
|
<span className="text-xs font-bold text-slate-600">{sub.label}</span>
|
|||
|
|
<select
|
|||
|
|
value={ratingVal ?? ''}
|
|||
|
|
onChange={e => {
|
|||
|
|
const v = e.target.value;
|
|||
|
|
updateInspection(cat.key, sub.key, 'rating', v === '' ? null : parseInt(v, 10));
|
|||
|
|
}}
|
|||
|
|
className="text-[10px] rounded-lg border border-slate-200 px-2 py-1"
|
|||
|
|
>
|
|||
|
|
<option value="">Оценка 1–5</option>
|
|||
|
|
{[1, 2, 3, 4, 5].map(n => (
|
|||
|
|
<option key={n} value={n}>{INSPECTION_SCORE_LABELS[n]}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
<label className="flex items-center gap-1 text-[10px] text-slate-500">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
checked={item?.noAccess ?? false}
|
|||
|
|
onChange={e => updateInspection(cat.key, sub.key, 'noAccess', e.target.checked)}
|
|||
|
|
/>
|
|||
|
|
Нет доступа
|
|||
|
|
</label>
|
|||
|
|
<label className="flex items-center gap-1 text-[10px] text-slate-500">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
checked={item?.notPresent ?? false}
|
|||
|
|
onChange={e => updateInspection(cat.key, sub.key, 'notPresent', e.target.checked)}
|
|||
|
|
/>
|
|||
|
|
Отсутствует в доме
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
<textarea
|
|||
|
|
placeholder="Описание"
|
|||
|
|
value={item?.description ?? ''}
|
|||
|
|
onChange={e => updateInspection(cat.key, sub.key, 'description', e.target.value || null)}
|
|||
|
|
className="w-full text-xs rounded-lg border border-slate-200 p-2 resize-none h-16"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
|
|||
|
|
<button type="button" onClick={onClose} className="px-5 py-2.5 bg-slate-100 text-slate-700 rounded-xl text-sm font-bold hover:bg-slate-200">
|
|||
|
|
Закрыть
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={handleSave}
|
|||
|
|
disabled={saving}
|
|||
|
|
className="px-5 py-2.5 bg-primary-600 text-white rounded-xl text-sm font-bold hover:bg-primary-700 disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
{saving ? 'Сохранение...' : 'Сохранить'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="p-12 text-center text-slate-500">Аудит не найден</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|