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

232 lines
14 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 } from 'react';
import { DevAuditData } from '../../types';
import { Download, BarChart3, AlertTriangle, CheckCircle2, TrendingUp, Info, ChevronRight, Briefcase } from 'lucide-react';
import { backendApi } from '../../services/apiClient';
import { AuditCardModal } from './AuditCardModal';
import { AUDIT_INSPECTION_SCHEMA, AUDIT_STATUS_LABELS, calcCategoryAverage, formatCategoryOverall } from './auditInspectionSchema';
import type { InspectionData } from '../../types';
const PIPELINE_SEARCH_EVENT = 'mkd-pipeline-search-request';
interface TechnicalAuditProps {
onNavigate?: (tab: string) => void;
}
export const TechnicalAudit: React.FC<TechnicalAuditProps> = ({ onNavigate }) => {
const [audits, setAudits] = useState<DevAuditData[]>([]);
const [loading, setLoading] = useState(true);
const [selectedAuditId, setSelectedAuditId] = useState<string | null>(null);
const fetchAudits = async () => {
try {
setLoading(true);
const data = await backendApi.getDevelopmentAudits();
setAudits(data);
} catch (error) {
console.error('Error fetching audits:', error);
setAudits([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAudits();
}, []);
const handleDownloadDefectList = async (e: React.MouseEvent, auditId: string) => {
e.stopPropagation();
try {
const result = await backendApi.getAuditDefectList(auditId);
if (result.url) {
window.open(result.url, '_blank');
} else {
alert('Дефектная ведомость будет сгенерирована на основе данных аудита');
}
} catch (error) {
console.error('Error downloading defect list:', error);
alert('Ошибка при загрузке дефектной ведомости');
}
};
const getStatusBadgeClass = (status: string) => {
if (status === 'completed') return 'bg-emerald-50 text-emerald-600';
if (status === 'in_progress') return 'bg-amber-50 text-amber-600';
return 'bg-slate-100 text-slate-600';
};
/** Среднее по пункту осмотра (15) из inspectionData; null — нет данных */
const getCategoryAverage = (audit: DevAuditData, categoryKey: string): number | null => {
const data = (audit.inspectionData ?? {}) as InspectionData;
const cat = data[categoryKey as keyof InspectionData];
const subItems = cat?.subItems ?? [];
return calcCategoryAverage(subItems);
};
/** Для списка: статус по среднему 15 или null → "—" */
const getCategoryDisplay = (audit: DevAuditData, categoryKey: string): { status: 'good' | 'fair' | 'bad' | null; label: string } => {
const avg = getCategoryAverage(audit, categoryKey);
if (avg == null) return { status: null, label: '—' };
if (avg >= 4) return { status: 'good', label: formatCategoryOverall(avg).split(' — ')[1] ?? 'Отлично' };
if (avg >= 2.5) return { status: 'fair', label: formatCategoryOverall(avg).split(' — ')[1] ?? 'Удовл.' };
return { status: 'bad', label: formatCategoryOverall(avg).split(' — ')[1] ?? 'Плохо' };
};
/** Есть ли в аудите хотя бы одна оценка по осмотру (не пустой/новый аудит) */
const hasInspectionRatings = (audit: DevAuditData): boolean => {
const data = (audit.inspectionData ?? {}) as InspectionData;
return AUDIT_INSPECTION_SCHEMA.some(cat => getCategoryAverage(audit, cat.key) != null);
};
return (
<div className="space-y-6 animate-fade-in">
{loading ? (
<div className="text-center py-20 text-slate-400">
<div className="w-10 h-10 border-2 border-slate-200 rounded-full mx-auto mb-2 animate-spin" />
<p className="text-[10px] font-bold uppercase tracking-widest">Загрузка...</p>
</div>
) : audits.length === 0 ? (
<div className="text-center py-20 text-slate-300">
<div className="w-10 h-10 border-2 border-dashed border-slate-200 rounded-full mx-auto mb-2" />
<p className="text-[10px] font-bold uppercase tracking-widest mb-2">Нет данных об аудитах</p>
<p className="text-xs text-slate-400 max-w-sm mx-auto mb-4">Аудит создаётся автоматически при переходе объекта воронки на этап «Анализ».</p>
{onNavigate && (
<button
type="button"
onClick={() => { onNavigate('pipeline'); }}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-50 text-primary-600 rounded-xl text-xs font-bold hover:bg-primary-100 transition-colors"
>
<Briefcase className="w-4 h-4"/> Перейти в воронку
</button>
)}
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{audits.map(audit => (
<div
key={audit.id}
role="button"
tabIndex={0}
onClick={() => setSelectedAuditId(audit.id)}
onKeyDown={(e) => e.key === 'Enter' && setSelectedAuditId(audit.id)}
className="bg-white rounded-[2.5rem] border border-slate-200 shadow-sm overflow-hidden flex flex-col group hover:border-primary-400 hover:shadow-md transition-all cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<div className="p-6 pb-0">
<div className="flex justify-between items-start mb-4">
<h4 className="font-black text-slate-800 text-lg leading-tight">{audit.address}</h4>
<span className={`text-[10px] font-black px-2 py-1 rounded uppercase tracking-tighter ${getStatusBadgeClass(audit.status || 'new')}`}>
{AUDIT_STATUS_LABELS[audit.status] || 'Новый'}
</span>
</div>
<div className="grid grid-cols-2 gap-4 mb-6">
<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">
<p className={`text-2xl font-black ${(audit.wearPercent ?? 0) > 50 ? 'text-red-600' : 'text-emerald-600'}`}>
{hasInspectionRatings(audit) && audit.wearPercent != null ? `${audit.wearPercent}%` : '—'}
</p>
{hasInspectionRatings(audit) && (audit.wearPercent ?? 0) > 50 ? <AlertTriangle className="w-4 h-4 text-red-500 animate-pulse"/> : <CheckCircle2 className="w-4 h-4 text-emerald-500"/>}
</div>
</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">
<p className="text-2xl font-black text-primary-600">
{audit.projectedMargin != null ? `${audit.projectedMargin}%` : '—'}
</p>
<TrendingUp className="w-4 h-4 text-primary-400" />
</div>
</div>
</div>
</div>
<div className="px-6 space-y-2 mb-6">
{AUDIT_INSPECTION_SCHEMA.map(cat => {
const { status, label } = getCategoryDisplay(audit, cat.key);
return (
<React.Fragment key={cat.key}>
<AuditRow label={cat.label} status={status} displayLabel={label} />
</React.Fragment>
);
})}
</div>
<div className="mt-auto bg-slate-900 p-6 text-white">
<div className="flex justify-between items-center mb-4">
<div>
<p className="text-[9px] font-black text-slate-500 uppercase tracking-widest">Прогнозный тариф</p>
<p className="text-xl font-black">{hasInspectionRatings(audit) && audit.calculatedTariff != null ? audit.calculatedTariff : '—'} <span className="text-xs font-normal text-slate-400">/м²</span></p>
</div>
<div className="flex items-center gap-2 flex-wrap">
{onNavigate && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onNavigate('pipeline');
window.dispatchEvent(new CustomEvent(PIPELINE_SEARCH_EVENT, { detail: { search: audit.address || '' } }));
}}
className="bg-white/10 text-white px-3 py-1.5 rounded-xl text-[10px] font-black uppercase flex items-center gap-1.5 hover:bg-white/20 transition-colors"
>
<Briefcase className="w-3.5 h-3.5"/> Воронка
</button>
)}
<button
type="button"
onClick={(e) => handleDownloadDefectList(e, audit.id)}
className="bg-white text-slate-900 px-4 py-2 rounded-xl text-[10px] font-black uppercase flex items-center gap-2 hover:bg-primary-50 transition-colors"
>
<Download className="w-4 h-4"/> Дефектная ведомость
</button>
<ChevronRight className="w-5 h-5 text-slate-400 group-hover:text-white" />
</div>
</div>
<div className="flex items-center gap-2 text-[9px] text-slate-400 font-medium">
<BarChart3 className="w-3.5 h-3.5 text-primary-400"/> Точка безубыточности: 28.5 /м²
</div>
</div>
</div>
))}
</div>
</>
)}
<AuditCardModal
auditId={selectedAuditId}
onClose={() => setSelectedAuditId(null)}
onSaved={() => { setSelectedAuditId(null); fetchAudits(); }}
/>
{audits.length > 0 && (
<div className="bg-blue-50 rounded-2xl p-4 border border-blue-100 flex items-start gap-3">
<Info className="w-5 h-5 text-blue-500 shrink-0 mt-0.5"/>
<div>
<p className="text-[11px] text-blue-700 leading-snug font-bold mb-1">Автоматическое создание аудитов</p>
<p className="text-[10px] text-blue-600 leading-snug">
Технический аудит создаётся автоматически при попадании объекта воронки на этап «Анализ».
Данные аудита используются для расчёта вероятности успеха и прогнозного тарифа.
</p>
</div>
</div>
)}
</div>
);
};
const AuditRow = ({ label, status, displayLabel }: { label: string; status: 'good' | 'fair' | 'bad' | null; displayLabel?: string }) => (
<div className="flex justify-between items-center p-3 bg-slate-50 rounded-2xl border border-slate-100 text-xs">
<span className="font-bold text-slate-600">{label}</span>
<span className={`text-[9px] font-black px-2 py-0.5 rounded uppercase tracking-tighter ${
status == null ? 'bg-slate-100 text-slate-400' :
status === 'bad' ? 'bg-red-50 text-red-600' :
status === 'good' ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600'
}`}>
{status == null ? (displayLabel ?? '—') : (displayLabel ?? (status === 'bad' ? 'Плохое' : status === 'good' ? 'Хорошее' : 'Удовл.'))}
</span>
</div>
);