1184 lines
82 KiB
TypeScript
1184 lines
82 KiB
TypeScript
|
|
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<React.SetStateAction<Building>>, isEditing: boolean }> = ({ building, setBuilding, isEditing }) => {
|
|||
|
|
const [showImportModal, setShowImportModal] = useState(false);
|
|||
|
|
const [importData, setImportData] = useState('');
|
|||
|
|
const [selectedYear, setSelectedYear] = useState(CURRENT_YEAR);
|
|||
|
|
const [viewMode, setViewMode] = useState<ViewMode>('table');
|
|||
|
|
const [showFromInspectionsModal, setShowFromInspectionsModal] = useState(false);
|
|||
|
|
const [postponeModalId, setPostponeModalId] = useState<string | null>(null);
|
|||
|
|
const [carryOverModalId, setCarryOverModalId] = useState<string | null>(null);
|
|||
|
|
const [postponeReasonInput, setPostponeReasonInput] = useState('');
|
|||
|
|
const [carryOverReasonInput, setCarryOverReasonInput] = useState('');
|
|||
|
|
const [fromInspectionsYear, setFromInspectionsYear] = useState(selectedYear);
|
|||
|
|
const [fromInspectionsMonth, setFromInspectionsMonth] = useState(getDefaultMonthForNewItem(selectedYear));
|
|||
|
|
const [fromInspectionsSelectedActIds, setFromInspectionsSelectedActIds] = useState<string[]>([]);
|
|||
|
|
const [fromInspectionsSelectedKeys, setFromInspectionsSelectedKeys] = useState<Record<string, boolean>>({});
|
|||
|
|
const [invoicesForBuilding, setInvoicesForBuilding] = useState<PaymentInvoice[]>([]);
|
|||
|
|
const [linkInvoicePlanItemId, setLinkInvoicePlanItemId] = useState<string | null>(null);
|
|||
|
|
const [showCreatePlanItemModal, setShowCreatePlanItemModal] = useState(false);
|
|||
|
|
const [createPlanItemMonth, setCreatePlanItemMonth] = useState<string | undefined>(undefined);
|
|||
|
|
const [workCardItemId, setWorkCardItemId] = useState<string | null>(null);
|
|||
|
|
type CreateTableRow = { workName: string; month: string; estimatedCost: number; byManagement: boolean };
|
|||
|
|
const [createTableRows, setCreateTableRows] = useState<CreateTableRow[]>([]);
|
|||
|
|
|
|||
|
|
// 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<PlanItem>) => {
|
|||
|
|
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<string, PaymentInvoice[]> = {};
|
|||
|
|
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 (
|
|||
|
|
<div className="space-y-6 animate-fade-in">
|
|||
|
|
{/* Year Selector Tabs */}
|
|||
|
|
<div className="flex gap-1 bg-slate-200/50 p-1 rounded-xl overflow-x-auto no-scrollbar">
|
|||
|
|
{availableYears.map(year => (
|
|||
|
|
<button
|
|||
|
|
key={year}
|
|||
|
|
onClick={() => setSelectedYear(year)}
|
|||
|
|
className={`flex-shrink-0 min-w-[5rem] px-4 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 whitespace-nowrap ${selectedYear === year ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:bg-white/50'}`}
|
|||
|
|
>
|
|||
|
|
{year < CURRENT_YEAR && <Archive className="w-3 h-3 opacity-50"/>}
|
|||
|
|
{year} {year === CURRENT_YEAR && '(Тек.)'}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Budget Summary Card */}
|
|||
|
|
<div className={`rounded-2xl p-6 text-white shadow-xl transition-colors duration-500 ${isArchive ? 'bg-gradient-to-br from-slate-600 to-slate-700' : 'bg-gradient-to-br from-slate-800 to-slate-900'}`}>
|
|||
|
|
<div className="flex justify-between items-start mb-6">
|
|||
|
|
<div>
|
|||
|
|
<h3 className="text-slate-300 text-[10px] font-bold uppercase tracking-wider mb-1">
|
|||
|
|
План работ на {selectedYear} год {isArchive && '(Архив)'}
|
|||
|
|
</h3>
|
|||
|
|
<p className="text-3xl font-black">{totalEstimated.toLocaleString()} ₽</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="p-3 bg-white/10 rounded-xl">
|
|||
|
|
<PieChart className="w-6 h-6 text-primary-400"/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-white/10">
|
|||
|
|
<div>
|
|||
|
|
<span className="text-xs text-slate-300 block mb-1">Фактически исполнено</span>
|
|||
|
|
<span className="text-lg font-bold">{totalActual.toLocaleString()} ₽</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="text-right">
|
|||
|
|
<span className="text-xs text-slate-300 block mb-1">Процент выполнения</span>
|
|||
|
|
<span className={`text-lg font-bold ${totalEstimated > 0 && (totalActual / totalEstimated) > 0.8 ? 'text-emerald-400' : 'text-amber-400'}`}>
|
|||
|
|
{totalEstimated > 0 ? Math.round((totalActual / totalEstimated) * 100) : 0}%
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* View toggle */}
|
|||
|
|
{!isArchive && (
|
|||
|
|
<div className="flex gap-1 bg-slate-200/50 p-1 rounded-xl w-fit flex-wrap">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setViewMode('table')}
|
|||
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all ${viewMode === 'table' ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:bg-white/50'}`}
|
|||
|
|
>
|
|||
|
|
<Table2 className="w-3.5 h-3.5"/> Таблица
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setViewMode('list')}
|
|||
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all ${viewMode === 'list' ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:bg-white/50'}`}
|
|||
|
|
>
|
|||
|
|
<List className="w-3.5 h-3.5"/> Список
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setViewMode('months')}
|
|||
|
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all ${viewMode === 'months' ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:bg-white/50'}`}
|
|||
|
|
>
|
|||
|
|
<LayoutGrid className="w-3.5 h-3.5"/> По месяцам
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Management Bar — Создать пункт открывает модалку во всех режимах */}
|
|||
|
|
{!isArchive && (
|
|||
|
|
<div className="flex flex-wrap gap-2">
|
|||
|
|
<button
|
|||
|
|
onClick={() => handleAddItem()}
|
|||
|
|
className="flex-1 min-w-[120px] py-3 bg-primary-600 text-white rounded-xl font-bold text-xs flex items-center justify-center gap-2 hover:bg-primary-700 transition-all shadow-lg shadow-primary-500/20 active:scale-95"
|
|||
|
|
>
|
|||
|
|
<Plus className="w-4 h-4"/> Создать пункт
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setShowFromInspectionsModal(true)}
|
|||
|
|
className="flex-1 min-w-[120px] py-3 bg-white border border-slate-200 text-slate-700 rounded-xl font-bold text-xs flex items-center justify-center gap-2 hover:bg-slate-50 transition-all active:scale-95"
|
|||
|
|
>
|
|||
|
|
<Camera className="w-4 h-4 text-primary-500"/> Пополнить из обходов
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setShowImportModal(true)}
|
|||
|
|
className="flex-1 min-w-[120px] py-3 bg-white border border-slate-200 text-slate-700 rounded-xl font-bold text-xs flex items-center justify-center gap-2 hover:bg-slate-50 transition-all active:scale-95"
|
|||
|
|
>
|
|||
|
|
<Upload className="w-4 h-4 text-primary-500"/> Импорт
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Empty plan hint */}
|
|||
|
|
{filteredPlan.length === 0 && (
|
|||
|
|
<div className="py-12 px-6 text-center bg-white rounded-2xl border border-dashed border-slate-300">
|
|||
|
|
<CalendarDays className="w-10 h-10 mx-auto mb-3 text-slate-300"/>
|
|||
|
|
<p className="text-sm font-medium text-slate-600 mb-4">План на {selectedYear} год не заполнен</p>
|
|||
|
|
<p className="text-xs text-slate-500 mb-6">Нажмите «Создать пункт», пополните из обходов или импортируйте CSV</p>
|
|||
|
|
{!isArchive && (
|
|||
|
|
<div className="flex flex-wrap gap-2 justify-center">
|
|||
|
|
<button type="button" onClick={() => handleAddItem()} className="py-2.5 px-4 bg-primary-600 text-white rounded-xl font-bold text-xs hover:bg-primary-700 transition-all flex items-center gap-1.5 mx-auto"><Plus className="w-4 h-4"/> Создать пункт</button>
|
|||
|
|
<button type="button" onClick={() => setShowFromInspectionsModal(true)} className="py-2.5 px-4 bg-white border border-slate-200 text-slate-700 rounded-xl font-bold text-xs hover:bg-slate-50 transition-all"><Camera className="w-4 h-4 inline mr-1"/> Пополнить из обходов</button>
|
|||
|
|
<button type="button" onClick={() => setShowImportModal(true)} className="py-2.5 px-4 bg-white border border-slate-200 text-slate-700 rounded-xl font-bold text-xs hover:bg-slate-50 transition-all"><Upload className="w-4 h-4 inline mr-1 text-primary-500"/> Импорт</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Overdue block */}
|
|||
|
|
{filteredPlan.length > 0 && overdueItems.length > 0 && (
|
|||
|
|
<div className="rounded-xl border-2 border-red-200 bg-red-50/30 p-4 space-y-3">
|
|||
|
|
<h3 className="text-xs font-black uppercase tracking-wider text-red-600 flex items-center gap-2">
|
|||
|
|
<AlertCircle className="w-4 h-4"/> Просроченные ({overdueItems.length})
|
|||
|
|
</h3>
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{overdueItems.map(item => (
|
|||
|
|
<div key={item.id} className="flex flex-wrap items-center justify-between gap-2 py-2 px-3 bg-white rounded-lg border border-red-100">
|
|||
|
|
<div>
|
|||
|
|
<span className="font-bold text-slate-800 text-sm block">{item.workName}</span>
|
|||
|
|
<span className="text-[10px] text-red-600 font-bold uppercase">{item.month}</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<span className="text-sm font-bold text-slate-700">{item.byManagement ? 'силами УК' : `${item.estimatedCost?.toLocaleString() ?? 0} ₽`}</span>
|
|||
|
|
<span className="text-[9px] font-black uppercase px-2 py-0.5 rounded bg-red-100 text-red-700">Просрочено</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Work Plan List (list view) or Months grid */}
|
|||
|
|
{filteredPlan.length > 0 && viewMode === 'list' && (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
{[...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 (
|
|||
|
|
<div
|
|||
|
|
key={item.id}
|
|||
|
|
role="button"
|
|||
|
|
tabIndex={0}
|
|||
|
|
onClick={() => 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' : ''}`}
|
|||
|
|
>
|
|||
|
|
<div className="flex justify-between items-start mb-3">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<div className={`p-1.5 rounded-lg ${item.status === 'completed' ? 'bg-emerald-50 text-emerald-600' : item.status === 'carried_over' ? 'bg-slate-200 text-slate-500' : 'bg-primary-50 text-primary-600'}`}>
|
|||
|
|
{item.status === 'completed' ? <CheckCircle2 className="w-4 h-4"/> : item.status === 'carried_over' ? <ArrowUpRight className="w-4 h-4"/> : <Clock className="w-4 h-4"/>}
|
|||
|
|
</div>
|
|||
|
|
<div className="flex flex-col" onClick={e => e.stopPropagation()}>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<EditableField
|
|||
|
|
value={item.month}
|
|||
|
|
onChange={(v) => handleUpdateItem(item.id, { month: v })}
|
|||
|
|
isEditing={isEditing && !isArchive}
|
|||
|
|
className={`text-[10px] font-black uppercase ${isOverdue ? 'text-red-500' : 'text-slate-400'}`}
|
|||
|
|
/>
|
|||
|
|
{isCarryOver && (
|
|||
|
|
<span className="bg-amber-100 text-amber-700 text-[8px] px-1.5 py-0.5 rounded font-black uppercase flex items-center gap-0.5">
|
|||
|
|
<History className="w-2 h-2"/> Перенос с {item.originalYear}
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
{isOverdue && <span className="text-[8px] font-bold text-red-600 flex items-center gap-0.5"><AlertCircle className="w-2 h-2"/> ПРОСРОЧЕНО</span>}
|
|||
|
|
{item.status === 'carried_over' && <span className="text-[8px] font-bold text-slate-500 uppercase">ПЕРЕНЕСЕНО НА {selectedYear + 1} Г.</span>}
|
|||
|
|
{item.postponeReason && <span className="text-[10px] text-slate-500 mt-0.5 block">Причина переноса: {item.postponeReason}</span>}
|
|||
|
|
{item.carryOverReason && isCarryOver && <span className="text-[10px] text-slate-500 mt-0.5 block">Причина переноса на год: {item.carryOverReason}</span>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-2 flex-wrap" onClick={e => e.stopPropagation()}>
|
|||
|
|
{isEditing && !isArchive && (
|
|||
|
|
<label className="flex items-center gap-1.5 text-[10px] font-bold text-slate-600 cursor-pointer">
|
|||
|
|
<input type="checkbox" checked={!!item.byManagement} onChange={() => handleUpdateItem(item.id, { byManagement: !item.byManagement })} className="rounded accent-primary-600" />
|
|||
|
|
силы УК
|
|||
|
|
</label>
|
|||
|
|
)}
|
|||
|
|
{item.byManagement ? (
|
|||
|
|
<span className="text-xs font-bold text-slate-600">силами УК</span>
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
<Wallet className="w-3.5 h-3.5 text-slate-300"/>
|
|||
|
|
<EditableField
|
|||
|
|
value={item.estimatedCost}
|
|||
|
|
onChange={(v) => handleUpdateItem(item.id, { estimatedCost: Number(v) })}
|
|||
|
|
isEditing={isEditing && !isArchive}
|
|||
|
|
type="number"
|
|||
|
|
className="text-sm font-bold text-slate-800 text-right w-20"
|
|||
|
|
/>
|
|||
|
|
<span className="text-[10px] text-slate-400 font-bold">₽</span>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="mb-4" onClick={e => e.stopPropagation()}>
|
|||
|
|
<EditableField
|
|||
|
|
value={item.workName}
|
|||
|
|
onChange={(v) => handleUpdateItem(item.id, { workName: v })}
|
|||
|
|
isEditing={isEditing && !isArchive}
|
|||
|
|
className="font-bold text-slate-700 text-sm w-full block"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Progress Bar and Actions */}
|
|||
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
|||
|
|
<div className="flex-1 space-y-1" onClick={e => e.stopPropagation()}>
|
|||
|
|
<div className="flex justify-between items-end">
|
|||
|
|
<span className="text-[9px] text-slate-400 font-bold uppercase tracking-tighter">Готовность объекта</span>
|
|||
|
|
<span className="text-[10px] font-black text-slate-700">{item.progress}%</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden">
|
|||
|
|
{isEditing && !isArchive ? (
|
|||
|
|
<input
|
|||
|
|
type="range" min="0" max="100"
|
|||
|
|
value={item.progress}
|
|||
|
|
onChange={(e) => 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"
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<div
|
|||
|
|
className={`h-full rounded-full transition-all duration-500 ${item.status === 'completed' ? 'bg-emerald-500' : 'bg-primary-500'}`}
|
|||
|
|
style={{ width: `${item.progress}%` }}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Quick Actions */}
|
|||
|
|
{!isEditing && !isArchive && item.status !== 'completed' && item.status !== 'carried_over' && (
|
|||
|
|
<div className="flex gap-1" onClick={e => e.stopPropagation()}>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => handleQuickComplete(item.id)}
|
|||
|
|
className="p-2 bg-emerald-50 text-emerald-600 rounded-lg hover:bg-emerald-100 transition-colors"
|
|||
|
|
title="Выполнено"
|
|||
|
|
>
|
|||
|
|
<CheckCircle2 className="w-4 h-4"/>
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => { setCarryOverReasonInput(''); setCarryOverModalId(item.id); }}
|
|||
|
|
className="p-2 bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 transition-colors"
|
|||
|
|
title={`Перенести на ${selectedYear + 1} год`}
|
|||
|
|
>
|
|||
|
|
<ArrowUpRight className="w-4 h-4"/>
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => {
|
|||
|
|
if (MONTHS.indexOf(item.month) === 11) {
|
|||
|
|
setCarryOverReasonInput(''); setCarryOverModalId(item.id);
|
|||
|
|
} else {
|
|||
|
|
setPostponeReasonInput(''); setPostponeModalId(item.id);
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
className="p-2 bg-amber-50 text-amber-600 rounded-lg hover:bg-amber-100 transition-colors"
|
|||
|
|
title="Перенести на след. месяц"
|
|||
|
|
>
|
|||
|
|
<ArrowRight className="w-4 h-4"/>
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => handleUpdateItem(item.id, { progress: 0, status: 'future' })}
|
|||
|
|
className="p-2 bg-slate-100 text-slate-500 rounded-lg hover:bg-slate-200 transition-colors"
|
|||
|
|
title="Отложить"
|
|||
|
|
>
|
|||
|
|
<PauseCircle className="w-4 h-4"/>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{isEditing && !isArchive && (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={e => { e.stopPropagation(); handleDeleteItem(item.id); }}
|
|||
|
|
className="p-2 text-red-300 hover:text-red-500 transition-colors"
|
|||
|
|
>
|
|||
|
|
<Trash2 className="w-5 h-5"/>
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Months grid view */}
|
|||
|
|
{filteredPlan.length > 0 && viewMode === 'months' && (
|
|||
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|||
|
|
{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 (
|
|||
|
|
<div key={month} className="bg-white rounded-xl border border-slate-200 shadow-sm p-4 flex flex-col">
|
|||
|
|
<div className="flex justify-between items-center mb-3">
|
|||
|
|
<span className="text-[10px] font-black uppercase text-slate-500">{month}</span>
|
|||
|
|
<span className="text-xs font-bold text-slate-700">{monthItems.length} работ · {monthTotal.toLocaleString()} ₽</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="space-y-2 flex-1 min-h-0 overflow-y-auto max-h-48">
|
|||
|
|
{monthItems.map(item => (
|
|||
|
|
<div
|
|||
|
|
key={item.id}
|
|||
|
|
role="button"
|
|||
|
|
tabIndex={0}
|
|||
|
|
onClick={() => 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"
|
|||
|
|
>
|
|||
|
|
<div className="flex justify-between items-center gap-2">
|
|||
|
|
<span className="text-xs font-medium text-slate-700 truncate flex-1">{item.workName}</span>
|
|||
|
|
<span className="text-[10px] font-bold text-slate-500 shrink-0">{item.byManagement ? 'силами УК' : `${item.estimatedCost?.toLocaleString() ?? 0} ₽`}</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex items-center gap-1.5 mt-0.5">
|
|||
|
|
<div className="flex-1 h-1 bg-slate-100 rounded-full overflow-hidden">
|
|||
|
|
<div className={`h-full rounded-full ${item.status === 'completed' ? 'bg-emerald-500' : 'bg-primary-500'}`} style={{ width: `${item.progress}%` }} />
|
|||
|
|
</div>
|
|||
|
|
<span className="text-[9px] font-bold text-slate-400 w-6">{item.progress}%</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
{!isArchive && (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => handleAddItem(month)}
|
|||
|
|
className="mt-3 w-full py-2 text-[10px] font-bold uppercase tracking-wider text-primary-600 border border-dashed border-primary-200 rounded-lg hover:bg-primary-50 transition-colors flex items-center justify-center gap-1"
|
|||
|
|
>
|
|||
|
|
<Plus className="w-3 h-3"/> Добавить в {month}
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Table — только просмотр; клик по строке открывает карточку работы; добавление через модалку */}
|
|||
|
|
{viewMode === 'table' && !isArchive && (
|
|||
|
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
|||
|
|
<div className="p-4 border-b border-slate-200 bg-slate-50/50">
|
|||
|
|
<h3 className="text-sm font-bold text-slate-800">План работ на {selectedYear} год</h3>
|
|||
|
|
<p className="text-xs text-slate-500 mt-0.5">Нажмите на строку, чтобы открыть карточку работы. Новый пункт — кнопка «Создать пункт» или «Добавить строку» ниже.</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="overflow-x-auto">
|
|||
|
|
<table className="w-full text-sm">
|
|||
|
|
<thead className="bg-slate-50 border-b border-slate-200 sticky top-0">
|
|||
|
|
<tr>
|
|||
|
|
<th className="p-3 text-left text-xs font-bold text-slate-600 uppercase">Работа</th>
|
|||
|
|
<th className="p-3 text-left text-xs font-bold text-slate-600 uppercase">Бюджет / силы УК</th>
|
|||
|
|
<th className="p-3 text-left text-xs font-bold text-slate-600 uppercase">Месяц</th>
|
|||
|
|
<th className="p-3 text-left text-xs font-bold text-slate-600 uppercase">План / Факт</th>
|
|||
|
|
<th className="p-3 text-center text-xs font-bold text-slate-600 uppercase w-12">Статус</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody className="divide-y divide-slate-100">
|
|||
|
|
{[...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 (
|
|||
|
|
<tr
|
|||
|
|
key={item.id}
|
|||
|
|
onClick={() => setWorkCardItemId(item.id)}
|
|||
|
|
className="hover:bg-primary-50/50 cursor-pointer transition-colors"
|
|||
|
|
>
|
|||
|
|
<td className="p-3 font-medium text-slate-800">{item.workName}</td>
|
|||
|
|
<td className="p-3 text-slate-700">{planLabel}</td>
|
|||
|
|
<td className="p-3 text-[10px] font-bold uppercase text-slate-500">{item.month}</td>
|
|||
|
|
<td className="p-3">
|
|||
|
|
<div className="flex flex-col gap-0.5">
|
|||
|
|
<span className="text-xs text-slate-600">План: {planLabel}</span>
|
|||
|
|
<span className="text-xs font-bold text-slate-800">Факт: {fact > 0 ? `${fact.toLocaleString()} ₽` : '—'}{invs.length > 0 && <span className="text-[10px] text-slate-400 ml-1">({invs.length} сч.)</span>}</span>
|
|||
|
|
</div>
|
|||
|
|
</td>
|
|||
|
|
<td className="p-3 text-center">
|
|||
|
|
{item.status === 'completed' && <CheckCircle2 className="w-5 h-5 text-emerald-500 mx-auto"/>}
|
|||
|
|
{item.status === 'carried_over' && <ArrowUpRight className="w-5 h-5 text-slate-400 mx-auto"/>}
|
|||
|
|
{(item.status === 'future' || item.status === 'current') && <span className="text-[10px] font-bold text-slate-400">{item.progress}%</span>}
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</tbody>
|
|||
|
|
<tfoot className="bg-slate-50 border-t-2 border-slate-200">
|
|||
|
|
<tr>
|
|||
|
|
<td colSpan={5} className="p-3">
|
|||
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|||
|
|
<span className="text-xs text-slate-500">Всего позиций: {filteredPlan.length}</span>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={(e) => { e.stopPropagation(); handleAddItem(); }}
|
|||
|
|
className="px-4 py-2 bg-primary-100 text-primary-700 rounded-lg text-sm font-bold hover:bg-primary-200 transition-colors flex items-center gap-2"
|
|||
|
|
>
|
|||
|
|
<Plus className="w-4 h-4" />
|
|||
|
|
Добавить строку
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
</tfoot>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{viewMode === 'table' && isArchive && filteredPlan.length > 0 && (
|
|||
|
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
|||
|
|
<div className="overflow-x-auto">
|
|||
|
|
<table className="w-full text-left">
|
|||
|
|
<thead><tr className="bg-slate-50 border-b border-slate-200">
|
|||
|
|
<th className="px-4 py-3 text-[10px] font-black uppercase text-slate-500">Работа</th>
|
|||
|
|
<th className="px-4 py-3 text-[10px] font-black uppercase text-slate-500">Бюджет / силы УК</th>
|
|||
|
|
<th className="px-4 py-3 text-[10px] font-black uppercase text-slate-500">Месяц</th>
|
|||
|
|
<th className="px-4 py-3 text-[10px] font-black uppercase text-slate-500">План / Факт</th>
|
|||
|
|
</tr></thead>
|
|||
|
|
<tbody>
|
|||
|
|
{[...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 (
|
|||
|
|
<tr key={item.id} className="border-b border-slate-100">
|
|||
|
|
<td className="px-4 py-3 font-medium text-slate-800 text-sm">{item.workName}</td>
|
|||
|
|
<td className="px-4 py-3 text-sm font-bold text-slate-700">{planLabel}</td>
|
|||
|
|
<td className="px-4 py-3 text-[10px] font-bold uppercase text-slate-500">{item.month}</td>
|
|||
|
|
<td className="px-4 py-3 text-xs">План: {planLabel} · Факт: {fact > 0 ? `${fact.toLocaleString()} ₽` : '—'}</td>
|
|||
|
|
</tr>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Модальное окно создания пунктов плана — табличная часть (сразу много строк) */}
|
|||
|
|
{showCreatePlanItemModal && (
|
|||
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={() => setShowCreatePlanItemModal(false)}>
|
|||
|
|
<div className="bg-white rounded-2xl w-full max-w-3xl max-h-[90vh] shadow-2xl animate-slide-up overflow-hidden flex flex-col" onClick={e => e.stopPropagation()}>
|
|||
|
|
<div className="p-6 border-b border-slate-200 flex-shrink-0">
|
|||
|
|
<div className="flex justify-between items-center">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<div className="p-2 bg-primary-50 text-primary-600 rounded-lg"><Plus className="w-5 h-5"/></div>
|
|||
|
|
<h3 className="text-lg font-bold text-slate-800">Создать пункты плана</h3>
|
|||
|
|
</div>
|
|||
|
|
<button type="button" onClick={() => setShowCreatePlanItemModal(false)} className="p-2 hover:bg-slate-100 rounded-full"><X className="w-5 h-5 text-slate-400"/></button>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-xs text-slate-500 mt-2">Год: {selectedYear}. Добавляйте строки, заполняйте таблицу и нажмите «Добавить в план» — все заполненные строки станут пунктами плана.</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex-1 overflow-auto p-6">
|
|||
|
|
<table className="w-full text-sm border border-slate-200 rounded-xl overflow-hidden">
|
|||
|
|
<thead className="bg-slate-50 border-b border-slate-200">
|
|||
|
|
<tr>
|
|||
|
|
<th className="p-2 text-left text-xs font-bold text-slate-600 uppercase">Работа</th>
|
|||
|
|
<th className="p-2 text-left text-xs font-bold text-slate-600 uppercase w-28">Месяц</th>
|
|||
|
|
<th className="p-2 text-left text-xs font-bold text-slate-600 uppercase">Бюджет / силы УК</th>
|
|||
|
|
<th className="p-2 w-10"></th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody className="divide-y divide-slate-100">
|
|||
|
|
{createTableRows.map((row, idx) => (
|
|||
|
|
<tr key={idx} className="hover:bg-slate-50/50">
|
|||
|
|
<td className="p-2">
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={row.workName}
|
|||
|
|
onChange={e => 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="Название работы"
|
|||
|
|
/>
|
|||
|
|
</td>
|
|||
|
|
<td className="p-2">
|
|||
|
|
<select
|
|||
|
|
value={row.month}
|
|||
|
|
onChange={e => setCreateTableRows(prev => prev.map((r, i) => i === idx ? { ...r, month: e.target.value } : r))}
|
|||
|
|
className="w-full px-2 py-1.5 border border-slate-300 rounded text-xs focus:ring-2 focus:ring-primary-500 outline-none"
|
|||
|
|
>
|
|||
|
|
{MONTHS.map(m => <option key={m} value={m}>{m}</option>)}
|
|||
|
|
</select>
|
|||
|
|
</td>
|
|||
|
|
<td className="p-2">
|
|||
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|||
|
|
<label className="flex items-center gap-1.5 cursor-pointer shrink-0">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
checked={row.byManagement}
|
|||
|
|
onChange={e => setCreateTableRows(prev => prev.map((r, i) => i === idx ? { ...r, byManagement: e.target.checked } : r))}
|
|||
|
|
className="rounded accent-primary-600"
|
|||
|
|
/>
|
|||
|
|
<span className="text-xs font-medium text-slate-700">силы УК</span>
|
|||
|
|
</label>
|
|||
|
|
{!row.byManagement && (
|
|||
|
|
<>
|
|||
|
|
<input
|
|||
|
|
type="number"
|
|||
|
|
value={row.estimatedCost || ''}
|
|||
|
|
onChange={e => 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}
|
|||
|
|
/>
|
|||
|
|
<span className="text-[10px] text-slate-500">₽</span>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</td>
|
|||
|
|
<td className="p-2">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setCreateTableRows(prev => prev.length > 1 ? prev.filter((_, i) => i !== idx) : prev)}
|
|||
|
|
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded"
|
|||
|
|
title="Удалить строку"
|
|||
|
|
>
|
|||
|
|
<Trash2 className="w-4 h-4"/>
|
|||
|
|
</button>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
))}
|
|||
|
|
</tbody>
|
|||
|
|
<tfoot className="bg-slate-50 border-t-2 border-slate-200">
|
|||
|
|
<tr>
|
|||
|
|
<td colSpan={4} className="p-3">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setCreateTableRows(prev => [...prev, { workName: '', month: createPlanItemMonth ?? getDefaultMonthForNewItem(selectedYear), estimatedCost: 0, byManagement: false }])}
|
|||
|
|
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm font-bold hover:bg-slate-200 transition-colors flex items-center gap-2"
|
|||
|
|
>
|
|||
|
|
<Plus className="w-4 h-4"/> Добавить строку
|
|||
|
|
</button>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
</tfoot>
|
|||
|
|
</table>
|
|||
|
|
<div className="flex gap-3 mt-6">
|
|||
|
|
<button type="button" onClick={() => setShowCreatePlanItemModal(false)} className="flex-1 py-2.5 text-slate-600 font-bold bg-slate-100 rounded-xl hover:bg-slate-200">Отмена</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => handleAddItemsFromTable(createTableRows)}
|
|||
|
|
disabled={!createTableRows.some(r => (r.workName || '').trim())}
|
|||
|
|
className="flex-1 py-2.5 text-white font-bold bg-primary-600 rounded-xl hover:bg-primary-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
|||
|
|
>
|
|||
|
|
<Plus className="w-4 h-4"/> Добавить в план ({createTableRows.filter(r => (r.workName || '').trim()).length} шт.)
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Карточка работы */}
|
|||
|
|
{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 (
|
|||
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={() => setWorkCardItemId(null)}>
|
|||
|
|
<div className="bg-white rounded-2xl w-full max-w-lg max-h-[90vh] overflow-hidden shadow-2xl animate-slide-up flex flex-col" onClick={e => e.stopPropagation()}>
|
|||
|
|
<div className="p-6 border-b border-slate-200 flex-shrink-0">
|
|||
|
|
<div className="flex justify-between items-start">
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<div className={`p-2.5 rounded-xl ${item.status === 'completed' ? 'bg-emerald-50 text-emerald-600' : item.status === 'carried_over' ? 'bg-slate-100 text-slate-500' : 'bg-primary-50 text-primary-600'}`}>
|
|||
|
|
{item.status === 'completed' ? <CheckCircle2 className="w-6 h-6"/> : item.status === 'carried_over' ? <ArrowUpRight className="w-6 h-6"/> : <Clock className="w-6 h-6"/>}
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<h3 className="text-lg font-bold text-slate-800">{item.workName}</h3>
|
|||
|
|
<p className="text-xs text-slate-500 mt-0.5">{item.month} · {selectedYear} г.</p>
|
|||
|
|
{isCarryOver && item.originalYear && <span className="text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded font-bold">Перенос с {item.originalYear} г.</span>}
|
|||
|
|
{isOverdue && <span className="text-[10px] text-red-600 bg-red-50 px-1.5 py-0.5 rounded font-bold block mt-1">Просрочено</span>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<button type="button" onClick={() => setWorkCardItemId(null)} className="p-2 hover:bg-slate-100 rounded-full"><X className="w-5 h-5 text-slate-400"/></button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div className="p-6 flex-1 overflow-y-auto space-y-4">
|
|||
|
|
<div className="grid grid-cols-2 gap-4">
|
|||
|
|
<div className="p-3 bg-slate-50 rounded-xl">
|
|||
|
|
<span className="text-[10px] font-bold uppercase text-slate-500 block mb-1">План</span>
|
|||
|
|
<span className="text-sm font-bold text-slate-800">{planLabel}</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="p-3 bg-slate-50 rounded-xl">
|
|||
|
|
<span className="text-[10px] font-bold uppercase text-slate-500 block mb-1">Факт</span>
|
|||
|
|
<span className="text-sm font-bold text-slate-800">{fact > 0 ? `${fact.toLocaleString()} ₽` : '—'}</span>
|
|||
|
|
{invs.length > 0 && <span className="text-[10px] text-slate-400 block">({invs.length} сч.)</span>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<span className="text-[10px] font-bold uppercase text-slate-500 block mb-2">Готовность</span>
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<div className="flex-1 h-3 bg-slate-100 rounded-full overflow-hidden">
|
|||
|
|
<div className={`h-full rounded-full ${item.status === 'completed' ? 'bg-emerald-500' : 'bg-primary-500'}`} style={{ width: `${item.progress}%` }} />
|
|||
|
|
</div>
|
|||
|
|
<span className="text-sm font-bold text-slate-700 w-10">{item.progress}%</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
{(item.postponeReason || (item.carryOverReason && isCarryOver)) && (
|
|||
|
|
<div className="p-3 bg-amber-50 rounded-xl text-sm text-slate-700">
|
|||
|
|
{item.postponeReason && <p><span className="font-bold text-amber-800">Причина переноса (месяц):</span> {item.postponeReason}</p>}
|
|||
|
|
{item.carryOverReason && isCarryOver && <p className="mt-1"><span className="font-bold text-amber-800">Причина переноса (год):</span> {item.carryOverReason}</p>}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{!isArchive && (
|
|||
|
|
<div className="flex flex-wrap gap-2 pt-2">
|
|||
|
|
<button type="button" onClick={() => setLinkInvoicePlanItemId(item.id)} className="px-3 py-2 bg-slate-100 text-slate-700 rounded-lg text-xs font-bold flex items-center gap-1.5 hover:bg-slate-200"><Paperclip className="w-4 h-4"/> Привязать счёт</button>
|
|||
|
|
{item.status !== 'completed' && item.status !== 'carried_over' && (
|
|||
|
|
<>
|
|||
|
|
<button type="button" onClick={() => { handleQuickComplete(item.id); setWorkCardItemId(null); }} className="px-3 py-2 bg-emerald-100 text-emerald-700 rounded-lg text-xs font-bold flex items-center gap-1.5 hover:bg-emerald-200"><CheckCircle2 className="w-4 h-4"/> Выполнено</button>
|
|||
|
|
<button type="button" onClick={() => { setCarryOverReasonInput(''); setCarryOverModalId(item.id); setWorkCardItemId(null); }} className="px-3 py-2 bg-blue-100 text-blue-700 rounded-lg text-xs font-bold flex items-center gap-1.5 hover:bg-blue-200"><ArrowUpRight className="w-4 h-4"/> На след. год</button>
|
|||
|
|
<button type="button" onClick={() => { setPostponeReasonInput(''); setPostponeModalId(item.id); setWorkCardItemId(null); }} className="px-3 py-2 bg-amber-100 text-amber-700 rounded-lg text-xs font-bold flex items-center gap-1.5 hover:bg-amber-200"><ArrowRight className="w-4 h-4"/> На след. месяц</button>
|
|||
|
|
<button type="button" onClick={() => { handleUpdateItem(item.id, { progress: 0, status: 'future' }); }} className="px-3 py-2 bg-slate-100 text-slate-600 rounded-lg text-xs font-bold hover:bg-slate-200"><PauseCircle className="w-4 h-4"/> Отложить</button>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
<button type="button" onClick={() => { if (confirm('Удалить этот пункт плана?')) { handleDeleteItem(item.id); setWorkCardItemId(null); } }} className="px-3 py-2 bg-red-50 text-red-600 rounded-lg text-xs font-bold hover:bg-red-100 flex items-center gap-1.5"><Trash2 className="w-4 h-4"/> Удалить</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})()}
|
|||
|
|
|
|||
|
|
{/* Link invoice to plan item modal */}
|
|||
|
|
{linkInvoicePlanItemId && (() => {
|
|||
|
|
const it = filteredPlan.find(p => p.id === linkInvoicePlanItemId);
|
|||
|
|
const unlinked = invoicesForBuilding.filter(inv => !inv.planItemId);
|
|||
|
|
return (
|
|||
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={() => setLinkInvoicePlanItemId(null)}>
|
|||
|
|
<div className="bg-white rounded-2xl w-full max-w-lg p-6 shadow-2xl animate-slide-up max-h-[80vh] overflow-hidden flex flex-col" onClick={e => e.stopPropagation()}>
|
|||
|
|
<div className="flex justify-between items-center mb-4">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<Paperclip className="w-5 h-5 text-primary-600"/>
|
|||
|
|
<h3 className="text-lg font-bold text-slate-800">Привязать счёт к работе</h3>
|
|||
|
|
</div>
|
|||
|
|
<button onClick={() => setLinkInvoicePlanItemId(null)} className="p-2 hover:bg-slate-100 rounded-full"><X className="w-5 h-5 text-slate-400"/></button>
|
|||
|
|
</div>
|
|||
|
|
{it && <p className="text-sm text-slate-600 mb-4">«{it.workName}»</p>}
|
|||
|
|
<div className="flex-1 overflow-y-auto min-h-0">
|
|||
|
|
{unlinked.length === 0 ? (
|
|||
|
|
<p className="text-sm text-slate-500 py-4">Нет непривязанных счетов по этому дому. Создайте счёт в разделе Финансы и отметьте «Из плана работ».</p>
|
|||
|
|
) : (
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{unlinked.map(inv => (
|
|||
|
|
<button
|
|||
|
|
key={inv.id}
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => handleLinkInvoice(inv.id, linkInvoicePlanItemId!)}
|
|||
|
|
className="w-full text-left p-3 rounded-xl border border-slate-200 hover:border-primary-300 hover:bg-primary-50/50 transition-colors"
|
|||
|
|
>
|
|||
|
|
<span className="font-medium text-slate-800 block">{inv.invoiceNumber} · {inv.contractorName}</span>
|
|||
|
|
<span className="text-xs text-slate-500">{inv.totalAmount?.toLocaleString()} ₽</span>
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})()}
|
|||
|
|
|
|||
|
|
{/* Postpone (next month) modal */}
|
|||
|
|
{postponeModalId && (() => {
|
|||
|
|
const it = filteredPlan.find(p => p.id === postponeModalId);
|
|||
|
|
if (!it) return null;
|
|||
|
|
return (
|
|||
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={() => { setPostponeModalId(null); setPostponeReasonInput(''); }}>
|
|||
|
|
<div className="bg-white rounded-2xl w-full max-w-md p-6 shadow-2xl animate-slide-up" onClick={e => e.stopPropagation()}>
|
|||
|
|
<h3 className="text-lg font-bold text-slate-800 mb-2">Перенести на следующий месяц</h3>
|
|||
|
|
<p className="text-sm text-slate-600 mb-4">«{it.workName}»</p>
|
|||
|
|
<label className="block text-xs font-bold text-slate-500 uppercase mb-2">Причина переноса</label>
|
|||
|
|
<textarea
|
|||
|
|
value={postponeReasonInput}
|
|||
|
|
onChange={e => setPostponeReasonInput(e.target.value)}
|
|||
|
|
placeholder="Укажите причину (необязательно)"
|
|||
|
|
className="w-full h-24 p-3 bg-slate-50 border border-slate-200 rounded-xl text-sm resize-none focus:ring-2 focus:ring-primary-500 outline-none"
|
|||
|
|
/>
|
|||
|
|
<div className="flex gap-3 mt-6">
|
|||
|
|
<button onClick={() => { setPostponeModalId(null); setPostponeReasonInput(''); }} className="flex-1 py-2.5 text-slate-600 font-bold bg-slate-100 rounded-xl hover:bg-slate-200">Отмена</button>
|
|||
|
|
<button onClick={() => { handlePostpone(postponeModalId, postponeReasonInput || undefined); setPostponeModalId(null); setPostponeReasonInput(''); }} className="flex-1 py-2.5 text-white font-bold bg-primary-600 rounded-xl hover:bg-primary-700">Перенести</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})()}
|
|||
|
|
|
|||
|
|
{/* Carry-over (next year) modal */}
|
|||
|
|
{carryOverModalId && (() => {
|
|||
|
|
const it = filteredPlan.find(p => p.id === carryOverModalId);
|
|||
|
|
if (!it) return null;
|
|||
|
|
return (
|
|||
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={() => { setCarryOverModalId(null); setCarryOverReasonInput(''); }}>
|
|||
|
|
<div className="bg-white rounded-2xl w-full max-w-md p-6 shadow-2xl animate-slide-up" onClick={e => e.stopPropagation()}>
|
|||
|
|
<h3 className="text-lg font-bold text-slate-800 mb-2">Перенести на {selectedYear + 1} год</h3>
|
|||
|
|
<p className="text-sm text-slate-600 mb-4">«{it.workName}»</p>
|
|||
|
|
<label className="block text-xs font-bold text-slate-500 uppercase mb-2">Причина переноса на следующий год</label>
|
|||
|
|
<textarea
|
|||
|
|
value={carryOverReasonInput}
|
|||
|
|
onChange={e => setCarryOverReasonInput(e.target.value)}
|
|||
|
|
placeholder="Укажите причину (необязательно)"
|
|||
|
|
className="w-full h-24 p-3 bg-slate-50 border border-slate-200 rounded-xl text-sm resize-none focus:ring-2 focus:ring-primary-500 outline-none"
|
|||
|
|
/>
|
|||
|
|
<div className="flex gap-3 mt-6">
|
|||
|
|
<button onClick={() => { setCarryOverModalId(null); setCarryOverReasonInput(''); }} className="flex-1 py-2.5 text-slate-600 font-bold bg-slate-100 rounded-xl hover:bg-slate-200">Отмена</button>
|
|||
|
|
<button onClick={() => { handleCarryOver(carryOverModalId, selectedYear + 1, carryOverReasonInput || undefined); setCarryOverModalId(null); setCarryOverReasonInput(''); }} className="flex-1 py-2.5 text-white font-bold bg-primary-600 rounded-xl hover:bg-primary-700">Перенести</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})()}
|
|||
|
|
|
|||
|
|
{/* Пополнить из обходов modal */}
|
|||
|
|
{showFromInspectionsModal && (
|
|||
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={() => setShowFromInspectionsModal(false)}>
|
|||
|
|
<div className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden shadow-2xl animate-slide-up flex flex-col" onClick={e => e.stopPropagation()}>
|
|||
|
|
<div className="p-6 border-b border-slate-200 flex-shrink-0">
|
|||
|
|
<div className="flex justify-between items-center">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<div className="p-2 bg-primary-50 text-primary-600 rounded-lg"><Camera className="w-5 h-5"/></div>
|
|||
|
|
<h3 className="text-lg font-bold text-slate-800">Пополнить план из обходов</h3>
|
|||
|
|
</div>
|
|||
|
|
<button onClick={() => setShowFromInspectionsModal(false)} className="p-2 hover:bg-slate-100 rounded-full"><X className="w-5 h-5 text-slate-400"/></button>
|
|||
|
|
</div>
|
|||
|
|
{inspectionActsWithCandidates.length === 0 ? (
|
|||
|
|
<p className="text-sm text-slate-500 mt-4">Нет осмотров с замечаниями. Проведите обход с фотофиксацией и отметьте элементы с ремонтом.</p>
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
<div className="flex flex-wrap gap-4 mt-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Год</label>
|
|||
|
|
<select value={fromInspectionsYear} onChange={e => setFromInspectionsYear(Number(e.target.value))} className="px-3 py-2 border border-slate-200 rounded-lg text-sm font-medium">
|
|||
|
|
{availableYears.map(y => <option key={y} value={y}>{y}</option>)}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-[10px] font-bold uppercase text-slate-500 mb-1">Месяц</label>
|
|||
|
|
<select value={fromInspectionsMonth} onChange={e => setFromInspectionsMonth(e.target.value)} className="px-3 py-2 border border-slate-200 rounded-lg text-sm font-medium">
|
|||
|
|
{MONTHS.map(m => <option key={m} value={m}>{m}</option>)}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<p className="text-xs text-slate-500 mt-2">Выберите акты осмотра, затем отметьте замечания для добавления в план.</p>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
{inspectionActsWithCandidates.length > 0 && (
|
|||
|
|
<>
|
|||
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
|||
|
|
{inspectionActsWithCandidates.map(act => {
|
|||
|
|
const count = inspectionCandidates.filter(c => c.actId === act.id).length;
|
|||
|
|
const checked = fromInspectionsSelectedActIds.includes(act.id);
|
|||
|
|
return (
|
|||
|
|
<div key={act.id} className="border border-slate-200 rounded-xl overflow-hidden">
|
|||
|
|
<label className="flex items-center gap-3 p-3 bg-slate-50 cursor-pointer hover:bg-slate-100">
|
|||
|
|
<input type="checkbox" checked={checked} onChange={() => setFromInspectionsSelectedActIds(prev => checked ? prev.filter(id => id !== act.id) : [...prev, act.id])} className="rounded accent-primary-600" />
|
|||
|
|
<span className="font-bold text-slate-800">Акт № {act.number}</span>
|
|||
|
|
<span className="text-slate-500 text-sm">{act.date}</span>
|
|||
|
|
<span className="text-xs font-bold text-amber-600 bg-amber-50 px-2 py-0.5 rounded">{count} замечаний</span>
|
|||
|
|
</label>
|
|||
|
|
{checked && (
|
|||
|
|
<div className="p-3 pt-0 space-y-2">
|
|||
|
|
{inspectionCandidates.filter(c => c.actId === act.id).map(c => {
|
|||
|
|
const key = `${c.actId}|${c.element.id}`;
|
|||
|
|
const sel = !!fromInspectionsSelectedKeys[key];
|
|||
|
|
return (
|
|||
|
|
<label key={key} className="flex items-start gap-3 p-2 rounded-lg hover:bg-slate-50 cursor-pointer">
|
|||
|
|
<input type="checkbox" checked={sel} onChange={() => setFromInspectionsSelectedKeys(prev => ({ ...prev, [key]: !prev[key] }))} className="mt-0.5 rounded accent-primary-600 shrink-0" />
|
|||
|
|
<div className="min-w-0 flex-1">
|
|||
|
|
<span className="font-medium text-slate-800 block">{c.element.name}</span>
|
|||
|
|
<div className="flex flex-wrap gap-2 mt-0.5">
|
|||
|
|
{c.sectionTitle && <span className="text-[10px] text-slate-500">{c.sectionTitle}</span>}
|
|||
|
|
{c.element.repairType?.trim() && <span className="text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">{c.element.repairType}</span>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</label>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
<div className="p-6 border-t border-slate-200 flex-shrink-0 flex gap-3">
|
|||
|
|
<button onClick={() => setShowFromInspectionsModal(false)} className="flex-1 py-2.5 text-slate-600 font-bold bg-slate-100 rounded-xl hover:bg-slate-200">Отмена</button>
|
|||
|
|
<button
|
|||
|
|
onClick={handleAddFromInspections}
|
|||
|
|
disabled={Object.keys(fromInspectionsSelectedKeys).filter(k => fromInspectionsSelectedKeys[k]).length === 0}
|
|||
|
|
className="flex-1 py-2.5 text-white font-bold bg-primary-600 rounded-xl hover:bg-primary-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
|||
|
|
>
|
|||
|
|
<Plus className="w-4 h-4"/> Добавить выбранные в план
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Import Modal */}
|
|||
|
|
{showImportModal && (
|
|||
|
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={() => setShowImportModal(false)}>
|
|||
|
|
<div className="bg-white rounded-2xl w-full max-w-xl p-6 shadow-2xl animate-slide-up" onClick={e => e.stopPropagation()}>
|
|||
|
|
<div className="flex justify-between items-center mb-4">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<div className="p-2 bg-primary-50 text-primary-600 rounded-lg"><FileSpreadsheet className="w-5 h-5"/></div>
|
|||
|
|
<h3 className="text-lg font-bold text-slate-800">Импорт плана в {selectedYear} год</h3>
|
|||
|
|
</div>
|
|||
|
|
<button onClick={() => setShowImportModal(false)} className="p-2 hover:bg-slate-100 rounded-full"><X className="w-5 h-5 text-slate-400"/></button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<textarea
|
|||
|
|
value={importData}
|
|||
|
|
onChange={(e) => setImportData(e.target.value)}
|
|||
|
|
placeholder="Работа;Месяц;Сумма (или силы УК)"
|
|||
|
|
className="w-full h-48 p-4 bg-slate-50 border border-slate-200 rounded-xl text-sm font-mono focus:ring-2 focus:ring-primary-500 outline-none resize-none"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<div className="mt-6 flex gap-3">
|
|||
|
|
<button onClick={() => setShowImportModal(false)} className="flex-1 py-3 text-slate-600 font-bold bg-slate-100 rounded-xl hover:bg-slate-200">Отмена</button>
|
|||
|
|
<button
|
|||
|
|
onClick={handleImportCSV}
|
|||
|
|
disabled={!importData.trim()}
|
|||
|
|
className="flex-1 py-3 text-white font-bold bg-primary-600 rounded-xl hover:bg-primary-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
|||
|
|
>
|
|||
|
|
<Upload className="w-4 h-4"/> Загрузить в {selectedYear} год
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|