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

521 lines
21 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, { useState, useMemo, useEffect, useCallback } from 'react';
import { MOCK_BUILDINGS } from '../constants';
import { Invoice, InvoiceStatus, PaymentInvoice, PaymentCalendarEntry } from '../types';
import { Plus } from 'lucide-react';
import { apiClient, authFetch } from '../services/apiClient';
function getCalendarPeriod(interval: 'week' | 'month'): { dateFrom: string; dateTo: string } {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
if (interval === 'week') {
const day = now.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
const monday = new Date(now);
monday.setDate(now.getDate() + mondayOffset);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
return {
dateFrom: monday.toISOString().split('T')[0],
dateTo: sunday.toISOString().split('T')[0]
};
}
const first = new Date(year, month, 1);
const last = new Date(year, month + 1, 0);
return {
dateFrom: first.toISOString().split('T')[0],
dateTo: last.toISOString().split('T')[0]
};
}
// Imported modular components
import { FinanceSummary } from './finance/FinanceSummary';
import { InvoiceRegistry } from './finance/InvoiceRegistry';
import { PaymentCalendar } from './finance/PaymentCalendar';
import { FinanceReports } from './finance/FinanceReports';
import { ReportUploader } from './finance/ReportUploader';
import { ReportProcessing } from './finance/ReportProcessing';
import { BuildingFinancialSummary } from './finance/BuildingFinancialSummary';
import { ReportsGrid } from './finance/ReportsGrid';
import { ReportDetailView } from './finance/ReportDetailView';
import { DebtorReportDetailView } from './finance/DebtorReportDetailView';
import { ReportTypesGrid } from './finance/ReportTypesGrid';
import { FinancialReport } from '../types';
import { PaymentInvoiceList } from './finance/PaymentInvoiceList';
import { PaymentInvoiceDetail } from './finance/PaymentInvoiceDetail';
import { PaymentInvoiceForm } from './finance/PaymentInvoiceForm';
import { allowedSubsForSection } from '../constants/permissions';
import { useSubPermission } from '../contexts/PermissionsContext';
import type { User } from '../types';
type FinanceTab = 'summary' | 'invoices' | 'calendar' | 'reports';
type ReportsView = 'types' | 'upload' | 'processing' | 'detail' | 'list';
type PaymentInvoicesView = 'list' | 'detail' | 'form';
const FINANCE_TABS: FinanceTab[] = ['summary', 'invoices', 'calendar', 'reports'];
const SUBTAB_KEY = 'mkd_subTab_finance';
export type FinanceInvoicePrefill = {
purposeType: 'event';
purposeEventId?: string;
purposeDescription?: string;
totalAmount?: number;
} | null;
interface FinanceModuleProps {
currentUser?: User | null;
externalInvoiceId?: number | null;
externalInvoicePrefill?: FinanceInvoicePrefill;
onInvoiceHandled?: () => void;
/** Детальные права: null/пусто = все вкладки по роли */
allowedPermissions?: string[] | null;
}
export const FinanceModule: React.FC<FinanceModuleProps> = ({ currentUser, externalInvoiceId, externalInvoicePrefill, onInvoiceHandled, allowedPermissions }) => {
const visibleTabs = useMemo(() => {
const allowed = allowedSubsForSection(allowedPermissions ?? [], 'finance');
if (allowed === 'all') return FINANCE_TABS;
return FINANCE_TABS.filter((t) => allowed.includes(t));
}, [allowedPermissions]);
const [activeTab, setActiveTab] = useState<FinanceTab>(() => {
const s = localStorage.getItem(SUBTAB_KEY);
const tab = (s && FINANCE_TABS.includes(s as FinanceTab)) ? s as FinanceTab : 'summary';
return tab;
});
const { canEdit: canEditCurrentTab, scopeOwn: scopeOwnCurrentTab } = useSubPermission('finance', activeTab);
useEffect(() => {
if (visibleTabs.length > 0 && !visibleTabs.includes(activeTab)) {
setActiveTab(visibleTabs[0]);
}
}, [visibleTabs, activeTab]);
useEffect(() => {
if (visibleTabs.includes(activeTab)) localStorage.setItem(SUBTAB_KEY, activeTab);
}, [activeTab, visibleTabs]);
const [reportsView, setReportsView] = useState<ReportsView>('types');
const [currentJobId, setCurrentJobId] = useState<string | null>(null);
const [currentReportId, setCurrentReportId] = useState<number | null>(null);
const [selectedReport, setSelectedReport] = useState<FinancialReport | null>(null);
const [selectedBuildingId, setSelectedBuildingId] = useState<string | null>(null);
const [selectedReportType, setSelectedReportType] = useState<string | undefined>(undefined);
const [selectedReportTypeName, setSelectedReportTypeName] = useState<string | undefined>(undefined);
const [totalReportsCount, setTotalReportsCount] = useState<number>(0);
// Новая система счетов на оплату
const [paymentInvoicesView, setPaymentInvoicesView] = useState<PaymentInvoicesView>('list');
const [selectedPaymentInvoice, setSelectedPaymentInvoice] = useState<PaymentInvoice | null>(null);
const [paymentInvoices, setPaymentInvoices] = useState<PaymentInvoice[]>([]);
const currentUserId = currentUser?.id ?? '';
const [formPrefill, setFormPrefill] = useState<FinanceInvoicePrefill>(null);
const [calendarEntries, setCalendarEntries] = useState<PaymentCalendarEntry[]>([]);
const [calendarInterval, setCalendarInterval] = useState<'week' | 'month'>('month');
const [invoices, setInvoices] = useState<Invoice[]>(() =>
MOCK_BUILDINGS.flatMap(b => b.financials.invoices.map(inv => ({
...inv,
closingDocsReceived: Math.random() > 0.5,
status: inv.status as InvoiceStatus
})))
);
const fetchCalendarEntries = useCallback(async () => {
const { dateFrom, dateTo } = getCalendarPeriod(calendarInterval);
try {
const res = await apiClient.get<{ entries: PaymentCalendarEntry[] }>(
`/finance/payment-calendar/entries?dateFrom=${dateFrom}&dateTo=${dateTo}&limit=500`
);
setCalendarEntries(res?.entries ?? []);
} catch (err) {
console.error('Error fetching payment calendar entries:', err);
setCalendarEntries([]);
}
}, [calendarInterval]);
// Загружаем счета и записи календаря при открытии вкладки «Календарь оплат»
useEffect(() => {
if (activeTab === 'calendar') {
fetchPaymentInvoices();
fetchCalendarEntries();
}
}, [activeTab, fetchCalendarEntries]);
// Загружаем количество отчетов для отображения на главном экране
useEffect(() => {
if (activeTab === 'reports' && reportsView === 'types') {
fetchReportsCount();
}
}, [activeTab, reportsView]);
const fetchReportsCount = async () => {
try {
const response = await authFetch('/api/finance/reports');
if (response.ok) {
const data = await response.json();
setTotalReportsCount(Array.isArray(data) ? data.length : 0);
}
} catch (err) {
console.error('Error fetching reports count:', err);
}
};
// Если пришел внешний запрос открыть конкретный счет
useEffect(() => {
const openFromExternal = async () => {
if (!externalInvoiceId) return;
try {
const invoice = await apiClient.get<PaymentInvoice>(`/finance/payment-invoices/${externalInvoiceId}`);
setSelectedPaymentInvoice(invoice);
setActiveTab('invoices');
setPaymentInvoicesView('detail');
if (onInvoiceHandled) {
onInvoiceHandled();
}
} catch (err) {
console.error('Error opening external invoice:', err);
}
};
openFromExternal();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalInvoiceId]);
// Если пришло предзаполнение формы счета (например, по мероприятию)
useEffect(() => {
if (!externalInvoicePrefill) return;
setFormPrefill(externalInvoicePrefill);
setPaymentInvoicesView('form');
setSelectedPaymentInvoice(null);
setActiveTab('invoices');
if (onInvoiceHandled) {
onInvoiceHandled();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalInvoicePrefill]);
const fetchPaymentInvoices = async () => {
try {
const response = await apiClient.get<{ invoices: PaymentInvoice[] }>('/finance/payment-invoices?status=scheduled&limit=100');
setPaymentInvoices(response.invoices);
} catch (err) {
console.error('Error fetching payment invoices:', err);
}
};
const financialData = useMemo(() => {
const totalBalance = MOCK_BUILDINGS.reduce((sum, b) => sum + b.financials.balance, 0);
const scheduledAmount = invoices.filter(i => i.status === 'scheduled').reduce((sum, i) => sum + i.amount, 0);
const totalDebt = MOCK_BUILDINGS.reduce((sum, b) => sum + b.financials.debt, 0);
const missingDocs = invoices.filter(i => i.status === 'paid' && !i.closingDocsReceived).length;
return { totalBalance, totalDebt, scheduledAmount, missingDocs };
}, [invoices]);
const updateInvoiceStatus = (id: string, newStatus: InvoiceStatus, extra?: Partial<Invoice>) => {
setInvoices(prev => prev.map(inv =>
inv.id === id ? { ...inv, status: newStatus, ...extra } : inv
));
};
const handleAddInvoice = () => {
setPaymentInvoicesView('form');
setSelectedPaymentInvoice(null);
setActiveTab('invoices');
};
const handleSavePaymentInvoice = async (invoiceData: Partial<PaymentInvoice>) => {
try {
if (selectedPaymentInvoice) {
// Обновление существующего счета
await apiClient.put(`/finance/payment-invoices/${selectedPaymentInvoice.id}`, invoiceData);
} else {
// Создание нового счета
await apiClient.post('/finance/payment-invoices', invoiceData);
}
setFormPrefill(null);
setPaymentInvoicesView('list');
setSelectedPaymentInvoice(null);
fetchPaymentInvoices();
} catch (err) {
console.error('Error saving payment invoice:', err);
throw err;
}
};
const handleUpdatePaymentInvoiceStatus = async (
id: number,
status: 'paid' | 'postponed' | 'cancelled',
payload?: { paymentDate?: string; paymentRef?: string; isCash?: boolean; postponedDate?: string; cancelReason?: string }
) => {
try {
await apiClient.post(`/finance/payment-invoices/${id}/update-payment-status`, {
status,
paymentDate: payload?.paymentDate,
paymentRef: payload?.paymentRef,
isCash: payload?.isCash,
postponedDate: payload?.postponedDate,
cancelReason: payload?.cancelReason
});
fetchPaymentInvoices();
} catch (err) {
console.error('Error updating payment invoice status:', err);
throw err;
}
};
return (
<div className="animate-fade-in pb-20 space-y-6">
<div className="flex flex-wrap justify-between items-center gap-4">
<h2 className="text-xl font-bold text-slate-800">Финансовый отдел</h2>
{canEditCurrentTab && activeTab === 'invoices' && (
<div className="flex gap-2">
<button onClick={handleAddInvoice} className="bg-primary-600 text-white p-2 rounded-xl shadow-lg shadow-primary-500/30 flex items-center gap-2 px-3 text-xs font-bold active:scale-95 transition-transform">
<Plus className="w-4 h-4" /> Новый счет
</button>
</div>
)}
</div>
{/* Tabs */}
<div className="flex p-1 bg-slate-200/50 rounded-xl overflow-x-auto no-scrollbar">
{[
{id: 'summary', label: 'Сводка'},
{id: 'invoices', label: 'Реестр счетов'},
{id: 'calendar', label: 'Календарь оплат'},
{id: 'reports', label: 'Отчеты'}
].filter((tab) => visibleTabs.includes(tab.id as FinanceTab)).map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as FinanceTab)}
className={`flex-shrink-0 min-w-[7rem] px-4 py-2 text-xs font-bold whitespace-nowrap rounded-lg transition-all ${activeTab === tab.id ? 'bg-white text-primary-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
{tab.label}
</button>
))}
</div>
{/* Sheet Content Rendering */}
<div className="min-h-[280px] sm:min-h-[360px] md:min-h-[500px]">
{activeTab === 'summary' && <FinanceSummary data={financialData} />}
{activeTab === 'invoices' && (
<>
{paymentInvoicesView === 'form' && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
<PaymentInvoiceForm
invoice={selectedPaymentInvoice || undefined}
initialPrefill={formPrefill ?? undefined}
currentUserId={currentUserId}
onSave={handleSavePaymentInvoice}
onCancel={() => {
setFormPrefill(null);
setPaymentInvoicesView('list');
setSelectedPaymentInvoice(null);
}}
/>
</div>
)}
{paymentInvoicesView === 'detail' && selectedPaymentInvoice && (
<PaymentInvoiceDetail
invoice={selectedPaymentInvoice}
currentUserId={currentUserId}
onBack={() => {
setPaymentInvoicesView('list');
setSelectedPaymentInvoice(null);
}}
onUpdate={() => {
fetchPaymentInvoices();
// Перезагружаем детали счета
apiClient.get(`/finance/payment-invoices/${selectedPaymentInvoice.id}`)
.then(inv => setSelectedPaymentInvoice(inv))
.catch(err => console.error('Error fetching invoice details:', err));
}}
/>
)}
{paymentInvoicesView === 'list' && (
<PaymentInvoiceList
onInvoiceClick={(invoice) => {
setSelectedPaymentInvoice(invoice);
setPaymentInvoicesView('detail');
}}
onCreateNew={handleAddInvoice}
currentUserId={currentUserId}
canEdit={canEditCurrentTab}
scopeOwn={scopeOwnCurrentTab}
/>
)}
</>
)}
{activeTab === 'calendar' && (
<PaymentCalendar
invoices={invoices}
paymentInvoices={paymentInvoices}
calendarEntries={calendarEntries}
onUpdateStatus={updateInvoiceStatus}
onUpdatePaymentInvoiceStatus={handleUpdatePaymentInvoiceStatus}
onRefreshCalendar={() => {
fetchPaymentInvoices();
fetchCalendarEntries();
}}
currentBalance={financialData.totalBalance}
currentUserId={currentUserId}
interval={calendarInterval}
onIntervalChange={setCalendarInterval}
/>
)}
{activeTab === 'reports' && (
<div className="space-y-6">
{/* Детальный просмотр отчета */}
{reportsView === 'detail' && selectedReport && selectedReport.reportType === 'debtors' && (
<DebtorReportDetailView
report={selectedReport}
onBack={() => {
setReportsView('list');
setSelectedReport(null);
}}
/>
)}
{reportsView === 'detail' && selectedReport && selectedReport.reportType !== 'debtors' && (
<ReportDetailView
report={selectedReport}
onBack={() => {
setReportsView('list');
setSelectedReport(null);
}}
/>
)}
{/* Список загруженных отчетов */}
{reportsView === 'list' && (
<ReportsGrid
onReportClick={(report) => {
setSelectedReport(report);
setReportsView('detail');
}}
reportTypeFilter={selectedReportType}
onBack={() => {
setReportsView('types');
setSelectedReportType(undefined);
}}
/>
)}
{/* Загрузка данных (старый вариант без предустановленного типа) */}
{reportsView === 'upload' && !selectedReportType && (
<ReportUploader
onUploadSuccess={(reportId, jobId) => {
setCurrentReportId(reportId);
setCurrentJobId(jobId ?? null);
window.dispatchEvent(new CustomEvent('mkd-finance-reports-changed'));
if (jobId) {
setReportsView('processing');
} else {
fetchReportsCount();
setReportsView('list');
}
}}
onUploadError={(error) => {
console.error('Ошибка загрузки:', error);
}}
onClose={() => {
setReportsView('types');
setSelectedReportType(undefined);
}}
/>
)}
{/* Обработка */}
{reportsView === 'processing' && currentJobId && (
<ReportProcessing
jobId={currentJobId}
reportId={currentReportId || undefined}
onComplete={() => {
window.dispatchEvent(new CustomEvent('mkd-finance-reports-changed'));
fetchReportsCount();
setReportsView('types');
setCurrentJobId(null);
setCurrentReportId(null);
setSelectedReportType(undefined);
}}
onViewReports={() => {
// Обновляем счетчик отчетов перед переходом к списку
fetchReportsCount();
setReportsView('list');
setCurrentJobId(null);
setCurrentReportId(null);
setSelectedReportType(undefined);
}}
/>
)}
{reportsView === 'processing' && !currentJobId && (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
<p className="text-slate-500">Нет активных процессов обработки</p>
<button
onClick={() => setReportsView('types')}
className="mt-4 text-primary-600 hover:text-primary-700 font-medium"
>
Вернуться к типам отчетов
</button>
</div>
)}
{/* Типы отчетов (основной экран) */}
{reportsView === 'types' && (
<ReportTypesGrid
onReportTypeClick={(reportType) => {
// При клике на квадратик открываем модальное окно загрузки с предустановленным типом
setSelectedReportType(reportType.reportType);
setSelectedReportTypeName(reportType.name);
setReportsView('upload');
}}
onViewReportsClick={() => {
setReportsView('list');
setSelectedReportType(undefined);
}}
totalReportsCount={totalReportsCount}
/>
)}
{/* Модальное окно загрузки с предустановленным типом */}
{reportsView === 'upload' && selectedReportType && (
<div className="relative">
<ReportUploader
presetReportType={selectedReportType as any}
reportTypeName={selectedReportTypeName}
onUploadSuccess={(reportId, jobId) => {
setCurrentReportId(reportId);
setCurrentJobId(jobId ?? null);
window.dispatchEvent(new CustomEvent('mkd-finance-reports-changed'));
fetchReportsCount();
if (jobId) {
setReportsView('processing');
} else {
setReportsView('list');
setSelectedReportType(undefined);
}
}}
onUploadError={(error) => {
console.error('Ошибка загрузки:', error);
}}
onViewReports={() => {
setReportsView('list');
setSelectedReportType(undefined);
setSelectedReportTypeName(undefined);
}}
onClose={() => {
setReportsView('types');
setSelectedReportType(undefined);
setSelectedReportTypeName(undefined);
}}
/>
</div>
)}
</div>
)}
</div>
</div>
);
};