521 lines
21 KiB
TypeScript
Executable File
521 lines
21 KiB
TypeScript
Executable File
|
||
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>
|
||
);
|
||
};
|