Files
mkd/components/development/AuditCardModal.tsx
2026-02-04 00:17:04 +05:00

294 lines
18 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};