import React, { useState, useMemo, useEffect, useCallback } from 'react'; import { Building, PlanItem, InspectionAct, InspectionSection, InspectionCommonSection, InspectionMKDElement, PaymentInvoice } from '../../types'; import { apiClient } from '../../services/apiClient'; import { Calendar, Wallet, CheckCircle2, Clock, Plus, Trash2, PieChart, Upload, ArrowRight, PauseCircle, CalendarDays, FileSpreadsheet, X, AlertCircle, History, Archive, ArrowUpRight, LayoutGrid, List, Camera, Table2, Paperclip } from 'lucide-react'; import { EditableField } from './EditableField'; const MONTHS = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; const CURRENT_YEAR = new Date().getFullYear(); const CURRENT_MONTH_IDX = new Date().getMonth(); function getDefaultMonthForNewItem(selectedYear: number): string { if (selectedYear === CURRENT_YEAR && CURRENT_MONTH_IDX <= 2) return 'Январь'; return MONTHS[CURRENT_MONTH_IDX]; } function isInspectionCandidate(el: InspectionMKDElement): boolean { const st = [el.generalStatus, el.electroStatus, el.weldingStatus]; if (st.some(s => s === 'WARNING' || s === 'CRITICAL')) return true; return !!el.repairType?.trim(); } type InspectionCandidate = { actId: string; actNumber: string; actDate: string; sectionTitle: string; element: InspectionMKDElement; }; function collectCandidatesFromInspections(history: InspectionAct[]): InspectionCandidate[] { const out: InspectionCandidate[] = []; for (const act of history) { const sections = act.sections || []; const common = act.commonSections || []; for (const s of sections) { for (const el of s.elements || []) { if (isInspectionCandidate(el)) out.push({ actId: act.id, actNumber: act.number, actDate: act.date, sectionTitle: s.title, element: el }); } } for (const s of common) { for (const el of s.elements || []) { if (isInspectionCandidate(el)) out.push({ actId: act.id, actNumber: act.number, actDate: act.date, sectionTitle: s.title, element: el }); } } } return out; } type ViewMode = 'list' | 'months' | 'table'; export const BudgetPlanView: React.FC<{ building: Building, setBuilding: React.Dispatch>, isEditing: boolean }> = ({ building, setBuilding, isEditing }) => { const [showImportModal, setShowImportModal] = useState(false); const [importData, setImportData] = useState(''); const [selectedYear, setSelectedYear] = useState(CURRENT_YEAR); const [viewMode, setViewMode] = useState('table'); const [showFromInspectionsModal, setShowFromInspectionsModal] = useState(false); const [postponeModalId, setPostponeModalId] = useState(null); const [carryOverModalId, setCarryOverModalId] = useState(null); const [postponeReasonInput, setPostponeReasonInput] = useState(''); const [carryOverReasonInput, setCarryOverReasonInput] = useState(''); const [fromInspectionsYear, setFromInspectionsYear] = useState(selectedYear); const [fromInspectionsMonth, setFromInspectionsMonth] = useState(getDefaultMonthForNewItem(selectedYear)); const [fromInspectionsSelectedActIds, setFromInspectionsSelectedActIds] = useState([]); const [fromInspectionsSelectedKeys, setFromInspectionsSelectedKeys] = useState>({}); const [invoicesForBuilding, setInvoicesForBuilding] = useState([]); const [linkInvoicePlanItemId, setLinkInvoicePlanItemId] = useState(null); const [showCreatePlanItemModal, setShowCreatePlanItemModal] = useState(false); const [createPlanItemMonth, setCreatePlanItemMonth] = useState(undefined); const [workCardItemId, setWorkCardItemId] = useState(null); type CreateTableRow = { workName: string; month: string; estimatedCost: number; byManagement: boolean }; const [createTableRows, setCreateTableRows] = useState([]); // Normalize existing data if years are missing const plan = useMemo(() => { return (building.annualPlan || []).map(item => ({ ...item, year: item.year || CURRENT_YEAR // Default to current if undefined })); }, [building.annualPlan]); const availableYears = useMemo(() => { const years = new Set([CURRENT_YEAR, CURRENT_YEAR + 1]); plan.forEach(p => years.add(p.year)); return Array.from(years).sort((a, b) => b - a); }, [plan]); const filteredPlan = plan.filter(item => item.year === selectedYear); const totalEstimated = filteredPlan.reduce((sum, item) => sum + (item.estimatedCost || 0), 0); const totalActual = filteredPlan.reduce((sum, item) => sum + (item.actualCost || 0), 0); const handleAddItem = (month?: string) => { setCreatePlanItemMonth(month ?? getDefaultMonthForNewItem(selectedYear)); setShowCreatePlanItemModal(true); }; const handleAddItemsFromTable = (rows: CreateTableRow[]) => { const toAdd = rows.filter(r => (r.workName || '').trim()); if (toAdd.length === 0) return; const baseTs = Date.now(); const newItems: PlanItem[] = toAdd.map((data, idx) => ({ id: `p-${baseTs}-${idx}`, year: selectedYear, month: data.month || getDefaultMonthForNewItem(selectedYear), workName: (data.workName || '').trim() || 'Новая работа', status: 'future' as const, progress: 0, estimatedCost: data.estimatedCost ?? 0, byManagement: data.byManagement ?? false })); setBuilding(prev => ({ ...prev, annualPlan: [...(prev.annualPlan || []), ...newItems], isDirty: true })); setShowCreatePlanItemModal(false); setCreatePlanItemMonth(undefined); setCreateTableRows([]); }; const handleUpdateItem = (id: string, updates: Partial) => { setBuilding(prev => ({ ...prev, annualPlan: (prev.annualPlan || []).map(item => item.id === id ? { ...item, ...updates } : item), isDirty: true })); }; const handleQuickComplete = (id: string) => { const item = plan.find(p => p.id === id); handleUpdateItem(id, { progress: 100, status: 'completed', actualCost: item?.estimatedCost }); }; const handlePostpone = (id: string, reason?: string) => { const currentItem = plan.find(p => p.id === id); if (!currentItem) return; const currentMonthIdx = MONTHS.indexOf(currentItem.month); if (currentMonthIdx === 11) return; // Dec -> use carry-over flow instead const nextMonth = MONTHS[currentMonthIdx + 1]; handleUpdateItem(id, { month: nextMonth, status: 'future', progress: 0, postponeReason: reason || undefined }); }; const handleCarryOver = (id: string, targetYear: number, reason?: string) => { const currentItem = plan.find(p => p.id === id); if (!currentItem) return; const carriedItem: PlanItem = { ...currentItem, id: `p-carried-${Date.now()}`, year: targetYear, originalYear: currentItem.originalYear || currentItem.year, status: 'future', progress: 0, month: 'Январь', carryOverReason: reason || undefined }; setBuilding(prev => { const base = prev.annualPlan || []; return { ...prev, annualPlan: [ ...base.map(item => item.id === id ? { ...item, status: 'carried_over' as const } : item), carriedItem ], isDirty: true }; }); }; const handleDeleteItem = (id: string) => { if (confirm('Удалить этот пункт плана?')) { setBuilding(prev => ({ ...prev, annualPlan: (prev.annualPlan || []).filter(item => item.id !== id), isDirty: true })); } }; const handleAddFromInspections = () => { const existingKeys = new Set((building.annualPlan || []).map(p => (p.sourceInspectionId && p.sourceElementId) ? `${p.sourceInspectionId}|${p.sourceElementId}` : null).filter(Boolean) as string[]); const toAdd = inspectionCandidates.filter(c => { const key = `${c.actId}|${c.element.id}`; return fromInspectionsSelectedKeys[key] && !existingKeys.has(key); }); const newItems: PlanItem[] = toAdd.map(c => { const workName = [c.element.name, c.element.repairType?.trim() && ` — ${c.element.repairType.trim()}`, c.sectionTitle && `, ${c.sectionTitle}`].filter(Boolean).join(''); return { id: `p-ins-${Date.now()}-${c.element.id}`, year: fromInspectionsYear, month: fromInspectionsMonth, workName, status: 'future', progress: 0, estimatedCost: 0, byManagement: false, sourceInspectionId: c.actId, sourceElementId: c.element.id, sourceSectionTitle: c.sectionTitle }; }); if (newItems.length === 0) return; setBuilding(prev => ({ ...prev, annualPlan: [...(prev.annualPlan || []), ...newItems], isDirty: true })); setShowFromInspectionsModal(false); }; const parsePlanRow = (row: string, idx: number, prefix: string): PlanItem => { const [name, month, cost] = row.split(';').map(s => s?.trim() ?? ''); const byManagement = /силами\s*ук|силами\s*у\.?к\.?/i.test(cost); const estimatedCost = byManagement ? 0 : (Number(cost?.replace(/\s/g, '')) || 0); return { id: `${prefix}-${Date.now()}-${idx}`, year: selectedYear, workName: name || 'Без названия', month: month || 'Январь', estimatedCost, byManagement, status: 'future', progress: 0 }; }; const handleImportCSV = () => { const rows = importData.split('\n').filter(r => r.trim()); const newItems: PlanItem[] = rows.map((row, idx) => parsePlanRow(row, idx, 'p-imp')); setBuilding(prev => ({ ...prev, annualPlan: [...(prev.annualPlan || []), ...newItems], isDirty: true })); setShowImportModal(false); setImportData(''); }; const handleLinkInvoice = async (invoiceId: number, planItemId: string) => { try { await apiClient.put(`/finance/payment-invoices/${invoiceId}`, { planItemId, planItemBuildingId: building.id }); await fetchInvoices(); setLinkInvoicePlanItemId(null); } catch (e) { console.error('Link invoice failed:', e); alert('Не удалось привязать счёт. Возможны только счета в статусе «Черновик» или «Отклонён».'); } }; const isArchive = selectedYear < CURRENT_YEAR; const overdueItems = useMemo(() => { if (selectedYear !== CURRENT_YEAR || isArchive) return []; const now = new Date().getMonth(); return filteredPlan.filter(p => p.status !== 'completed' && p.status !== 'carried_over' && MONTHS.indexOf(p.month) < now ); }, [filteredPlan, selectedYear, isArchive]); const inspectionCandidates = useMemo(() => collectCandidatesFromInspections(building.inspectionHistory || []), [building.inspectionHistory]); const inspectionActsWithCandidates = useMemo(() => { const actIds = new Set(inspectionCandidates.map(c => c.actId)); return (building.inspectionHistory || []).filter(a => actIds.has(a.id)); }, [building.inspectionHistory, inspectionCandidates]); useEffect(() => { if (showFromInspectionsModal) { setFromInspectionsYear(selectedYear); setFromInspectionsMonth(getDefaultMonthForNewItem(selectedYear)); setFromInspectionsSelectedActIds([]); setFromInspectionsSelectedKeys({}); } }, [showFromInspectionsModal, selectedYear]); useEffect(() => { if (showCreatePlanItemModal) { const defaultMonth = createPlanItemMonth ?? getDefaultMonthForNewItem(selectedYear); setCreateTableRows([{ workName: '', month: defaultMonth, estimatedCost: 0, byManagement: false }]); } }, [showCreatePlanItemModal, createPlanItemMonth, selectedYear]); const fetchInvoices = useCallback(async () => { try { const res = await apiClient.get<{ invoices: PaymentInvoice[] }>( `/finance/payment-invoices?buildingId=${encodeURIComponent(building.id)}&limit=500` ); const list = (res as any)?.invoices ?? []; setInvoicesForBuilding(Array.isArray(list) ? list : []); } catch { setInvoicesForBuilding([]); } }, [building.id]); useEffect(() => { fetchInvoices(); }, [fetchInvoices]); const invoicesByPlanItemId = useMemo(() => { const map: Record = {}; for (const inv of invoicesForBuilding) { if (inv.planItemId && inv.planItemBuildingId === building.id) { const k = inv.planItemId; if (!map[k]) map[k] = []; map[k].push(inv); } } return map; }, [invoicesForBuilding, building.id]); return (
{/* Year Selector Tabs */}
{availableYears.map(year => ( ))}
{/* Budget Summary Card */}

План работ на {selectedYear} год {isArchive && '(Архив)'}

{totalEstimated.toLocaleString()} ₽

Фактически исполнено {totalActual.toLocaleString()} ₽
Процент выполнения 0 && (totalActual / totalEstimated) > 0.8 ? 'text-emerald-400' : 'text-amber-400'}`}> {totalEstimated > 0 ? Math.round((totalActual / totalEstimated) * 100) : 0}%
{/* View toggle */} {!isArchive && (
)} {/* Management Bar — Создать пункт открывает модалку во всех режимах */} {!isArchive && (
)} {/* Empty plan hint */} {filteredPlan.length === 0 && (

План на {selectedYear} год не заполнен

Нажмите «Создать пункт», пополните из обходов или импортируйте CSV

{!isArchive && (
)}
)} {/* Overdue block */} {filteredPlan.length > 0 && overdueItems.length > 0 && (

Просроченные ({overdueItems.length})

{overdueItems.map(item => (
{item.workName} {item.month}
{item.byManagement ? 'силами УК' : `${item.estimatedCost?.toLocaleString() ?? 0} ₽`} Просрочено
))}
)} {/* Work Plan List (list view) or Months grid */} {filteredPlan.length > 0 && viewMode === 'list' && (
{[...filteredPlan].sort((a,b) => MONTHS.indexOf(a.month) - MONTHS.indexOf(b.month)).map((item) => { const isOverdue = selectedYear === CURRENT_YEAR && item.status !== 'completed' && item.status !== 'carried_over' && MONTHS.indexOf(item.month) < new Date().getMonth(); const isCarryOver = !!item.originalYear && item.originalYear < item.year; return (
setWorkCardItemId(item.id)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setWorkCardItemId(item.id); } }} className={`bg-white p-4 rounded-xl border shadow-sm relative group transition-all cursor-pointer hover:border-primary-300 hover:shadow-md ${isOverdue ? 'border-red-200 bg-red-50/10' : 'border-slate-200'} ${isArchive ? 'opacity-90 grayscale-[0.3]' : ''} ${item.status === 'carried_over' ? 'opacity-60 bg-slate-50' : ''}`} >
{item.status === 'completed' ? : item.status === 'carried_over' ? : }
e.stopPropagation()}>
handleUpdateItem(item.id, { month: v })} isEditing={isEditing && !isArchive} className={`text-[10px] font-black uppercase ${isOverdue ? 'text-red-500' : 'text-slate-400'}`} /> {isCarryOver && ( Перенос с {item.originalYear} )}
{isOverdue && ПРОСРОЧЕНО} {item.status === 'carried_over' && ПЕРЕНЕСЕНО НА {selectedYear + 1} Г.} {item.postponeReason && Причина переноса: {item.postponeReason}} {item.carryOverReason && isCarryOver && Причина переноса на год: {item.carryOverReason}}
e.stopPropagation()}> {isEditing && !isArchive && ( )} {item.byManagement ? ( силами УК ) : ( <> handleUpdateItem(item.id, { estimatedCost: Number(v) })} isEditing={isEditing && !isArchive} type="number" className="text-sm font-bold text-slate-800 text-right w-20" /> )}
e.stopPropagation()}> handleUpdateItem(item.id, { workName: v })} isEditing={isEditing && !isArchive} className="font-bold text-slate-700 text-sm w-full block" />
{/* Progress Bar and Actions */}
e.stopPropagation()}>
Готовность объекта {item.progress}%
{isEditing && !isArchive ? ( handleUpdateItem(item.id, { progress: Number(e.target.value), status: Number(e.target.value) === 100 ? 'completed' : 'current' })} className="w-full h-full accent-primary-600 cursor-pointer" /> ) : (
)}
{/* Quick Actions */} {!isEditing && !isArchive && item.status !== 'completed' && item.status !== 'carried_over' && (
e.stopPropagation()}>
)} {isEditing && !isArchive && ( )}
); })}
)} {/* Months grid view */} {filteredPlan.length > 0 && viewMode === 'months' && (
{MONTHS.map(month => { const monthItems = filteredPlan.filter(p => p.month === month); const monthTotal = monthItems.reduce((s, p) => s + (p.byManagement ? 0 : (p.estimatedCost || 0)), 0); return (
{month} {monthItems.length} работ · {monthTotal.toLocaleString()} ₽
{monthItems.map(item => (
setWorkCardItemId(item.id)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setWorkCardItemId(item.id); } }} className="py-1.5 border-b border-slate-100 last:border-0 cursor-pointer hover:bg-slate-50 rounded px-1 -mx-1 transition-colors" >
{item.workName} {item.byManagement ? 'силами УК' : `${item.estimatedCost?.toLocaleString() ?? 0} ₽`}
{item.progress}%
))}
{!isArchive && ( )}
); })}
)} {/* Table — только просмотр; клик по строке открывает карточку работы; добавление через модалку */} {viewMode === 'table' && !isArchive && (

План работ на {selectedYear} год

Нажмите на строку, чтобы открыть карточку работы. Новый пункт — кнопка «Создать пункт» или «Добавить строку» ниже.

{[...filteredPlan].sort((a, b) => MONTHS.indexOf(a.month) - MONTHS.indexOf(b.month)).map((item) => { const invs = invoicesByPlanItemId[item.id] ?? []; const fact = (invs.reduce((s, i) => s + (i.totalAmount ?? 0), 0) || item.actualCost) ?? 0; const planLabel = item.byManagement ? 'силами УК' : (item.estimatedCost?.toLocaleString() ?? 0) + ' ₽'; return ( setWorkCardItemId(item.id)} className="hover:bg-primary-50/50 cursor-pointer transition-colors" > ); })}
Работа Бюджет / силы УК Месяц План / Факт Статус
{item.workName} {planLabel} {item.month}
План: {planLabel} Факт: {fact > 0 ? `${fact.toLocaleString()} ₽` : '—'}{invs.length > 0 && ({invs.length} сч.)}
{item.status === 'completed' && } {item.status === 'carried_over' && } {(item.status === 'future' || item.status === 'current') && {item.progress}%}
Всего позиций: {filteredPlan.length}
)} {viewMode === 'table' && isArchive && filteredPlan.length > 0 && (
{[...filteredPlan].sort((a, b) => MONTHS.indexOf(a.month) - MONTHS.indexOf(b.month)).map((item) => { const invs = invoicesByPlanItemId[item.id] ?? []; const fact = (invs.reduce((s, i) => s + (i.totalAmount ?? 0), 0) || item.actualCost) ?? 0; const planLabel = item.byManagement ? 'силами УК' : (item.estimatedCost?.toLocaleString() ?? 0) + ' ₽'; return ( ); })}
Работа Бюджет / силы УК Месяц План / Факт
{item.workName} {planLabel} {item.month} План: {planLabel} · Факт: {fact > 0 ? `${fact.toLocaleString()} ₽` : '—'}
)} {/* Модальное окно создания пунктов плана — табличная часть (сразу много строк) */} {showCreatePlanItemModal && (
setShowCreatePlanItemModal(false)}>
e.stopPropagation()}>

Создать пункты плана

Год: {selectedYear}. Добавляйте строки, заполняйте таблицу и нажмите «Добавить в план» — все заполненные строки станут пунктами плана.

{createTableRows.map((row, idx) => ( ))}
Работа Месяц Бюджет / силы УК
setCreateTableRows(prev => prev.map((r, i) => i === idx ? { ...r, workName: e.target.value } : r))} className="w-full px-2 py-1.5 border border-slate-300 rounded text-sm focus:ring-2 focus:ring-primary-500 outline-none" placeholder="Название работы" />
{!row.byManagement && ( <> setCreateTableRows(prev => prev.map((r, i) => i === idx ? { ...r, estimatedCost: Number(e.target.value) || 0 } : r))} className="w-24 px-2 py-1.5 border border-slate-300 rounded text-sm text-right focus:ring-2 focus:ring-primary-500 outline-none" placeholder="0" min={0} /> )}
)} {/* Карточка работы */} {workCardItemId && (() => { const item = plan.find(p => p.id === workCardItemId); if (!item) return null; const invs = invoicesByPlanItemId[item.id] ?? []; const fact = (invs.reduce((s, i) => s + (i.totalAmount ?? 0), 0) || item.actualCost) ?? 0; const planLabel = item.byManagement ? 'силами УК' : (item.estimatedCost?.toLocaleString() ?? 0) + ' ₽'; const isOverdue = selectedYear === CURRENT_YEAR && item.status !== 'completed' && item.status !== 'carried_over' && MONTHS.indexOf(item.month) < new Date().getMonth(); const isCarryOver = !!item.originalYear && item.originalYear < item.year; return (
setWorkCardItemId(null)}>
e.stopPropagation()}>
{item.status === 'completed' ? : item.status === 'carried_over' ? : }

{item.workName}

{item.month} · {selectedYear} г.

{isCarryOver && item.originalYear && Перенос с {item.originalYear} г.} {isOverdue && Просрочено}
План {planLabel}
Факт {fact > 0 ? `${fact.toLocaleString()} ₽` : '—'} {invs.length > 0 && ({invs.length} сч.)}
Готовность
{item.progress}%
{(item.postponeReason || (item.carryOverReason && isCarryOver)) && (
{item.postponeReason &&

Причина переноса (месяц): {item.postponeReason}

} {item.carryOverReason && isCarryOver &&

Причина переноса (год): {item.carryOverReason}

}
)} {!isArchive && (
{item.status !== 'completed' && item.status !== 'carried_over' && ( <> )}
)}
); })()} {/* Link invoice to plan item modal */} {linkInvoicePlanItemId && (() => { const it = filteredPlan.find(p => p.id === linkInvoicePlanItemId); const unlinked = invoicesForBuilding.filter(inv => !inv.planItemId); return (
setLinkInvoicePlanItemId(null)}>
e.stopPropagation()}>

Привязать счёт к работе

{it &&

«{it.workName}»

}
{unlinked.length === 0 ? (

Нет непривязанных счетов по этому дому. Создайте счёт в разделе Финансы и отметьте «Из плана работ».

) : (
{unlinked.map(inv => ( ))}
)}
); })()} {/* Postpone (next month) modal */} {postponeModalId && (() => { const it = filteredPlan.find(p => p.id === postponeModalId); if (!it) return null; return (
{ setPostponeModalId(null); setPostponeReasonInput(''); }}>
e.stopPropagation()}>

Перенести на следующий месяц

«{it.workName}»