Files
mkd/components/FinanceModule.tsx

521 lines
21 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
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>
);
};