297 lines
15 KiB
TypeScript
297 lines
15 KiB
TypeScript
|
|
|
|||
|
|
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>
|
|||
|
|
);
|
|||
|
|
};
|