Initial commit MKD fixes
This commit is contained in:
231
components/development/TechnicalAudit.tsx
Executable file
231
components/development/TechnicalAudit.tsx
Executable 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';
|
||||
};
|
||||
|
||||
/** Среднее по пункту осмотра (1–5) из 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);
|
||||
};
|
||||
|
||||
/** Для списка: статус по среднему 1–5 или 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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user