Files
mkd/components/building/FinanceView.tsx
2026-02-04 00:17:04 +05:00

297 lines
15 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useState } from 'react';
import { Building, Invoice, InvoiceStatus, PaymentInvoice, PaymentInvoiceStatus } from '../../types';
import { Download, Plus, Activity, CheckCircle2, X, Trash2, Calendar, FileCheck, AlertCircle, Clock, Wallet } from 'lucide-react';
import { EditableField } from './EditableField';
import { apiClient } from '../../services/apiClient';
import { PaymentInvoiceDetail } from '../finance/PaymentInvoiceDetail';
export const FinanceView: React.FC<{ building: Building, setBuilding: React.Dispatch<React.SetStateAction<Building>>, isEditing: boolean }> = ({ building, setBuilding, isEditing }) => {
const invoices = building.financials.invoices || [];
// Счета на оплату из финансового модуля (payment_invoices)
const [paymentInvoices, setPaymentInvoices] = useState<PaymentInvoice[]>([]);
const [loadingPaymentInvoices, setLoadingPaymentInvoices] = useState(false);
const [selectedPaymentInvoice, setSelectedPaymentInvoice] = useState<PaymentInvoice | null>(null);
const currentUserId = 'user-1'; // TODO: взять из системы авторизации
const paymentStatusConfig: Record<PaymentInvoiceStatus, { label: string; color: string; bg: string }> = {
draft: { label: 'Черновик', color: 'text-slate-500', bg: 'bg-slate-100' },
pending_manager_approval: { label: 'На согл. руков.', color: 'text-amber-600', bg: 'bg-amber-50' },
pending_finance_manager_approval: { label: 'На согл. фин. руков.', color: 'text-blue-600', bg: 'bg-blue-50' },
approved: { label: 'Согласован', color: 'text-indigo-600', bg: 'bg-indigo-50' },
scheduled: { label: 'В графике', color: 'text-purple-600', bg: 'bg-purple-50' },
paid: { label: 'Оплачен', color: 'text-emerald-600', bg: 'bg-emerald-50' },
postponed: { label: 'Отложен', color: 'text-orange-600', bg: 'bg-orange-50' },
cancelled: { label: 'Отменен', color: 'text-red-600', bg: 'bg-red-50' },
rejected: { label: 'Отклонен', color: 'text-red-600', bg: 'bg-red-50' },
completed: { label: 'Выполнено', color: 'text-green-600', bg: 'bg-green-50' },
};
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
setLoadingPaymentInvoices(true);
// Берем только "building" и фильтруем по текущему дому на фронте
const resp = await apiClient.get<{ invoices: PaymentInvoice[] }>(
`/finance/payment-invoices?purposeType=building&limit=200`
);
if (cancelled) return;
const filtered = (resp?.invoices || []).filter((inv) =>
Array.isArray(inv.purposeBuildingIds) && inv.purposeBuildingIds.includes(building.id)
);
setPaymentInvoices(filtered);
} catch (e) {
if (!cancelled) setPaymentInvoices([]);
console.warn('[FinanceView] Failed to load payment invoices:', e);
} finally {
if (!cancelled) setLoadingPaymentInvoices(false);
}
};
load();
return () => {
cancelled = true;
};
}, [building.id]);
const refreshPaymentInvoices = async () => {
try {
setLoadingPaymentInvoices(true);
const resp = await apiClient.get<{ invoices: PaymentInvoice[] }>(
`/finance/payment-invoices?purposeType=building&limit=200`
);
const filtered = (resp?.invoices || []).filter((inv) =>
Array.isArray(inv.purposeBuildingIds) && inv.purposeBuildingIds.includes(building.id)
);
setPaymentInvoices(filtered);
} catch (e) {
console.warn('[FinanceView] Failed to refresh payment invoices:', e);
} finally {
setLoadingPaymentInvoices(false);
}
};
const paymentInvoicesTotals = useMemo(() => {
const total = paymentInvoices.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
const scheduled = paymentInvoices
.filter((i) => i.status === 'scheduled')
.reduce((sum, i) => sum + (Number(i.totalAmount) || 0), 0);
return { total, scheduled };
}, [paymentInvoices]);
// Calculate Totals
const scheduledTotal = paymentInvoices.length > 0
? paymentInvoicesTotals.scheduled
: invoices.filter(i => i.status === 'scheduled' || i.status === 'overdue').reduce((sum, i) => sum + i.amount, 0);
const balance = building.financials.balance;
const hasBalance = balance > 0;
const hasScheduled = scheduledTotal > 0;
const isDeficit = scheduledTotal > balance;
const handleAddInvoice = () => {
const newInvoice: Invoice = {
id: `inv-${Date.now()}`,
buildingId: building.id,
address: building.passport.address,
contractorName: 'Новый Контрагент',
serviceName: 'Услуга по содержанию',
amount: 0,
date: new Date().toISOString().split('T')[0],
status: 'pending_approval',
priority: 'medium',
closingDocsReceived: false
};
setBuilding(prev => ({
...prev,
financials: { ...prev.financials, invoices: [newInvoice, ...prev.financials.invoices] }
}));
};
/**
* Updates an invoice with a specific field or a partial object of updates
*/
const updateInvoice = (id: string, field: keyof Invoice, value: any, extra?: Partial<Invoice>) => {
setBuilding(prev => ({
...prev,
financials: {
...prev.financials,
invoices: prev.financials.invoices.map(inv => inv.id === id ? { ...inv, [field]: value, ...extra } : inv)
}
}));
};
const deleteInvoice = (id: string) => {
setBuilding(prev => ({
...prev,
financials: {
...prev.financials,
invoices: prev.financials.invoices.filter(i => i.id !== id)
}
}));
};
return (
<div className="space-y-6 animate-fade-in">
{/* 1. Cash Flow Visual */}
<div className={`bg-white p-5 rounded-2xl border shadow-sm relative group ${isEditing ? 'border-primary-200' : 'border-slate-200'}`}>
<div className="flex justify-between items-center mb-4">
<h3 className="font-bold text-slate-800 flex items-center gap-2">
<Activity className="w-5 h-5 text-indigo-500" /> Финансовый резерв
</h3>
<span className={`text-[10px] font-black px-2 py-1 rounded tracking-widest uppercase ${isDeficit ? 'bg-red-500 text-white animate-pulse' : 'bg-emerald-100 text-emerald-600'}`}>
{isDeficit ? 'ДЕФИЦИТ СРЕДСТВ' : 'РЕЗЕРВ OK'}
</span>
</div>
<div className="mb-4 grid grid-cols-2 gap-4">
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100">
<p className="text-[9px] font-bold text-slate-400 uppercase mb-1">Доступно</p>
<div className="flex items-center gap-1">
<EditableField value={balance} onChange={(v) => setBuilding({...building, financials: {...building.financials, balance: Number(v)}})} isEditing={isEditing} type="number" className="text-lg font-black text-slate-900 w-24"/>
<span className="font-bold text-slate-400"></span>
</div>
<p className="text-[10px] text-slate-400 mt-1">
{hasBalance
? `После оплат останется ≈ ${(balance - scheduledTotal).toLocaleString('ru-RU')}`
: 'На счетах нет средств'}
</p>
</div>
<div className="p-3 bg-slate-50 rounded-xl border border-slate-100">
<p className="text-[9px] font-bold text-slate-400 uppercase mb-1">В графике оплат</p>
<p className={`text-lg font-black ${hasScheduled ? 'text-slate-900' : 'text-slate-400'}`}>
{scheduledTotal.toLocaleString()}
</p>
<p className="text-[10px] text-slate-400 mt-1">
{hasScheduled ? 'Платежи в графике есть' : 'Платежей в графике нет'}
</p>
</div>
</div>
<div className="relative h-2 bg-slate-100 rounded-full overflow-hidden flex mb-2">
<div
className={`h-full ${isDeficit ? 'bg-red-500' : 'bg-indigo-500'}`}
style={{ width: `${Math.min((scheduledTotal / (balance || 1)) * 100, 100)}%` }}
/>
</div>
<p className="text-[9px] text-slate-400 text-right font-medium">Загрузка бюджета на тек. неделю: {Math.round((scheduledTotal / (balance || 1)) * 100)}%</p>
</div>
{/* 2. Actions */}
{isEditing && (
<button onClick={handleAddInvoice} className="w-full p-3 bg-primary-100 text-primary-700 rounded-xl font-bold text-sm flex items-center justify-center gap-2 border border-primary-200 hover:bg-primary-200 transition-colors">
<Plus className="w-4 h-4" /> Добавить счет
</button>
)}
{/* 3. Реестр счетов по дому (единый источник) */}
<div className="bg-white border border-slate-200 rounded-2xl shadow-sm overflow-hidden">
<div className="p-4 bg-slate-50 border-b border-slate-200 flex justify-between items-center">
<div>
<h3 className="font-black text-slate-700 text-[10px] uppercase tracking-widest">Счета по дому</h3>
<p className="text-[10px] text-slate-400 font-bold mt-1">
Источник: финансовый модуль {paymentInvoices.length} шт. Сумма: {paymentInvoicesTotals.total.toLocaleString('ru-RU')}
</p>
</div>
</div>
<div className="divide-y divide-slate-100">
{!loadingPaymentInvoices && paymentInvoices.length === 0 && (
<div className="p-10 text-center text-slate-400 text-sm">Счетов нет</div>
)}
{paymentInvoices.slice(0, 50).map((inv) => (
<div
key={inv.id}
className={`p-4 transition-colors cursor-pointer ${isEditing ? 'bg-primary-50/20' : 'hover:bg-slate-50'}`}
onClick={() => setSelectedPaymentInvoice(inv)}
>
<div className="flex justify-between items-start gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<span
className={`text-[9px] font-black uppercase px-2 py-0.5 rounded ${
paymentStatusConfig[inv.status]?.bg ||
'bg-slate-100'
} ${
paymentStatusConfig[inv.status]?.color ||
'text-slate-600'
}`}
>
{paymentStatusConfig[inv.status]?.label || inv.status}
</span>
<span className="text-xs text-slate-500 font-mono truncate">{inv.invoiceNumber}</span>
{inv.scheduledDate && (
<span className="text-[10px] text-slate-400 font-bold flex items-center gap-1">
<Calendar className="w-3 h-3" /> {new Date(inv.scheduledDate).toLocaleDateString('ru-RU')}
</span>
)}
</div>
<div className="text-sm font-bold text-slate-800 truncate">{inv.contractorName}</div>
<div className="text-xs text-slate-500 truncate">{inv.serviceDescription || '—'}</div>
</div>
<div className="text-right flex-shrink-0">
<div className="font-black text-slate-800">
{Number(inv.totalAmount || 0).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<div className="text-[10px] text-slate-400">
{inv.createdAt ? new Date(inv.createdAt).toLocaleDateString('ru-RU') : ''}
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Modal: просмотр/доработка счета */}
{selectedPaymentInvoice && (
<div className="fixed inset-0 z-[120] bg-slate-900/70 backdrop-blur-sm p-4 flex items-center justify-center">
<div className="w-full max-w-5xl max-h-[90vh] overflow-auto bg-transparent">
<div className="bg-white rounded-2xl border border-slate-200 shadow-2xl overflow-hidden">
<PaymentInvoiceDetail
invoice={selectedPaymentInvoice}
currentUserId={currentUserId}
onBack={() => setSelectedPaymentInvoice(null)}
onUpdate={async () => {
// Перезагружаем счет (чтобы статус/поля обновились)
try {
const fresh = await apiClient.get<PaymentInvoice>(`/finance/payment-invoices/${selectedPaymentInvoice.id}`);
setSelectedPaymentInvoice(fresh);
} catch (e) {
console.warn('[FinanceView] Failed to refresh selected invoice:', e);
}
await refreshPaymentInvoices();
}}
/>
</div>
</div>
</div>
)}
</div>
);
};
const StatusBadge: React.FC<{ status: InvoiceStatus }> = ({ status }) => {
const config: Record<InvoiceStatus, { label: string, color: string, bg: string }> = {
draft: { label: 'Черновик', color: 'text-slate-500', bg: 'bg-slate-100' },
pending_approval: { label: 'На согл.', color: 'text-amber-600', bg: 'bg-amber-50' },
approved: { label: 'Согласован', color: 'text-blue-600', bg: 'bg-blue-50' },
scheduled: { label: 'В графике', color: 'text-indigo-600', bg: 'bg-indigo-50' },
paid: { label: 'Оплачен', color: 'text-emerald-600', bg: 'bg-emerald-50' },
rejected: { label: 'Отказ', color: 'text-red-600', bg: 'bg-red-50' },
overdue: { label: 'Просрочен', color: 'text-white', bg: 'bg-red-500' },
// FIX: Added missing 'clarification' status to config to satisfy Record<InvoiceStatus, ...> type.
clarification: { label: 'Уточнение', color: 'text-purple-600', bg: 'bg-purple-50' },
};
const s = config[status] || config.draft;
return (
<span className={`px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-wider ${s.bg} ${s.color}`}>
{s.label}
</span>
);
};