Initial commit MKD fixes

This commit is contained in:
Arsen
2026-02-04 00:17:04 +05:00
commit de94ad707b
312 changed files with 138754 additions and 0 deletions

View File

@@ -0,0 +1,231 @@
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>
);