Files
mkd/components/development/AuditCardModal.tsx

294 lines
18 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
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;
};
/** Итог по пункту = среднее арифметическое подпунктов (только 15, без 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>
{/* Износ дома — только из данных пунктов осмотра (средние 15 → %), без ручного ввода */}
<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">Из средних по пунктам осмотра (15)</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">Индекс сложности дома (0100)</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">Из средних по пунктам осмотра (15)</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="Среднее арифметическое по подпунктам (только оценки 15)">
Среднее: {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="">Оценка 15</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>
);
};