import React, { useState, useMemo, useEffect } from 'react'; import { Invoice, InvoiceStatus, PaymentInvoice, PaymentCalendarEntry, PaymentDirection, FinanceAccount } from '../../types'; import { apiClient } from '../../services/apiClient'; import { Calendar, CheckCircle2, Wallet, History, Pause, XCircle, Plus, ArrowDownCircle, ArrowUpCircle, Banknote, Landmark } from 'lucide-react'; import { PaymentCalendarEntryForm } from './PaymentCalendarEntryForm'; import { PaymentStatusModal, PaymentStatusAction, PaymentStatusModalPayload } from './PaymentStatusModal'; type CalendarInterval = 'week' | 'month'; function getPeriodBounds(interval: CalendarInterval): { dateFrom: string; dateTo: string; label: 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); const dateFrom = monday.toISOString().split('T')[0]; const dateTo = sunday.toISOString().split('T')[0]; const options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short' }; const label = `${monday.toLocaleDateString('ru-RU', options)} – ${sunday.toLocaleDateString('ru-RU', options)} ${year}`; return { dateFrom, dateTo, label }; } const first = new Date(year, month, 1); const last = new Date(year, month + 1, 0); const dateFrom = first.toISOString().split('T')[0]; const dateTo = last.toISOString().split('T')[0]; const label = first.toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' }); return { dateFrom, dateTo, label }; } interface PaymentCalendarProps { invoices?: Invoice[]; paymentInvoices?: PaymentInvoice[]; calendarEntries?: PaymentCalendarEntry[]; onUpdateStatus?: (id: string, s: InvoiceStatus, extra?: any) => void; onUpdatePaymentInvoiceStatus?: (id: number, status: 'paid' | 'postponed' | 'cancelled', payload?: PaymentStatusModalPayload) => Promise; onRefreshCalendar?: () => void; currentBalance: number; currentUserId: string; interval?: CalendarInterval; onIntervalChange?: (interval: CalendarInterval) => void; periodLabel?: string; } type RowItem = { id: string; direction: PaymentDirection; amount: number; scheduledDate: string; contractorName: string; description: string; probability?: string; isCash?: boolean; status?: string; paymentInvoiceId?: number; isOld?: boolean; type: 'invoice' | 'entry'; entryId?: number; currency?: string; }; export const PaymentCalendar: React.FC = ({ invoices = [], paymentInvoices = [], calendarEntries = [], onUpdateStatus, onUpdatePaymentInvoiceStatus, onRefreshCalendar, currentBalance, currentUserId, interval = 'month', onIntervalChange, periodLabel: periodLabelProp }) => { const [filter, setFilter] = useState<'all' | 'planned' | 'paid'>('all'); const [directionFilter, setDirectionFilter] = useState<'all' | 'outgoing' | 'incoming'>('all'); const [loading, setLoading] = useState>({}); const [showEntryForm, setShowEntryForm] = useState(false); const [defaultFormDirection, setDefaultFormDirection] = useState('outgoing'); const [statusModal, setStatusModal] = useState<{ action: PaymentStatusAction; row: RowItem } | null>(null); const [financeAccounts, setFinanceAccounts] = useState([]); const { dateFrom, dateTo, label } = useMemo(() => getPeriodBounds(interval), [interval]); useEffect(() => { const load = async () => { try { const list = await apiClient.get('/finance/accounts'); setFinanceAccounts(Array.isArray(list) ? list : []); } catch { setFinanceAccounts([]); } }; load(); }, []); const periodLabel = periodLabelProp ?? label; const invoiceRows: RowItem[] = useMemo(() => { const invList = [ ...invoices.map((inv) => ({ id: inv.id, direction: 'outgoing' as PaymentDirection, amount: Number(inv.amount) || 0, scheduledDate: inv.scheduledDate || '', contractorName: inv.contractorName, description: inv.serviceName, status: inv.status, isOld: true, type: 'invoice' as const, paymentInvoiceId: undefined })), ...paymentInvoices.map((inv) => ({ id: `pi-${inv.id}`, direction: 'outgoing' as PaymentDirection, amount: Number(inv.totalAmount) || 0, scheduledDate: inv.scheduledDate || '', contractorName: inv.contractorName, description: inv.serviceDescription, status: inv.status, isOld: false, type: 'invoice' as const, paymentInvoiceId: inv.id })) ]; return invList; }, [invoices, paymentInvoices]); const entryRows: RowItem[] = useMemo(() => { const invoiceIds = new Set(paymentInvoices.map((pi) => pi.id)); return calendarEntries .filter((e) => !e.paymentInvoiceId || !invoiceIds.has(e.paymentInvoiceId)) .map((e) => ({ id: `entry-${e.id}`, direction: e.direction, amount: Number(e.amount) || 0, scheduledDate: e.scheduledDate, contractorName: e.contractorName, description: e.description || e.category, probability: e.probability, isCash: e.isCash, type: 'entry' as const, entryId: e.id, currency: e.currency })); }, [calendarEntries, paymentInvoices]); const allRows = useMemo(() => { const combined = [...invoiceRows, ...entryRows]; return combined .filter((r) => { if (directionFilter !== 'all' && r.direction !== directionFilter) return false; if (filter === 'planned') { if (r.type === 'invoice') return r.status === 'scheduled' || r.status === 'overdue'; return !r.scheduledDate ? false : true; } if (filter === 'paid') { if (r.type === 'invoice') return r.status === 'paid'; return false; } return true; }) .sort((a, b) => (a.scheduledDate || '').localeCompare(b.scheduledDate || '')); }, [invoiceRows, entryRows, filter, directionFilter]); const scheduledTotalOutgoing = invoiceRows.filter((i) => i.status === 'scheduled').reduce((s, i) => s + Number(i.amount) || 0, 0) + entryRows.filter((e) => e.direction === 'outgoing').reduce((s, e) => s + Number(e.amount) || 0, 0); const scheduledTotalIncoming = entryRows .filter((e) => e.direction === 'incoming') .reduce((s, e) => s + Number(e.amount) || 0, 0); const handlePaymentActionClick = (row: RowItem, action: 'paid' | 'postponed' | 'cancelled') => { if (row.type !== 'invoice' || row.isOld) { if (onUpdateStatus) onUpdateStatus(row.id, action === 'paid' ? 'paid' : 'scheduled'); return; } if (row.paymentInvoiceId && onUpdatePaymentInvoiceStatus) { setStatusModal({ action, row }); } }; const handleStatusModalConfirm = async (payload: PaymentStatusModalPayload) => { if (!statusModal || !statusModal.row.paymentInvoiceId || !onUpdatePaymentInvoiceStatus) return; const { row, action } = statusModal; const id = row.paymentInvoiceId!; setStatusModal(null); setLoading((prev) => ({ ...prev, [id]: true })); try { await onUpdatePaymentInvoiceStatus(id, action, payload); } catch (err) { console.error('Error updating payment invoice status:', err); alert('Ошибка при обновлении статуса'); } finally { setLoading((prev) => ({ ...prev, [id]: false })); } }; const handleDeleteEntry = async (entryId: number) => { if (!window.confirm('Удалить запись из календаря?')) return; try { await apiClient.delete(`/finance/payment-calendar/entries/${entryId}`); onRefreshCalendar?.(); } catch (err) { console.error('Error deleting entry:', err); alert('Ошибка удаления'); } }; const probabilityLabel: Record = { confirmed: '100%', high: '80%', medium: '50%', low: '30%' }; return (
{onIntervalChange && (
)} {financeAccounts.length > 0 ? (
{financeAccounts.filter((a) => a.type === 'bank').map((a) => (

{a.name}

{Number(a.balance).toLocaleString('ru-RU')} ₽

))} {financeAccounts.filter((a) => a.type === 'cash').map((a) => (

{a.name}

{Number(a.balance).toLocaleString('ru-RU')} ₽

))}

Итого: {financeAccounts.reduce((s, a) => s + Number(a.balance), 0).toLocaleString('ru-RU')} ₽

) : (

Текущий остаток

{currentBalance.toLocaleString()} ₽

)}

Платежный календарь

{periodLabel}

{Number(scheduledTotalOutgoing).toLocaleString('ru-RU')} ₽

К оплате (план)

+{Number(scheduledTotalIncoming).toLocaleString('ru-RU')} ₽

Ожидаемые поступления

{allRows.length === 0 && (
Нет записей за выбранный период
)} {allRows.map((row) => { const isPaid = row.type === 'invoice' && row.status === 'paid'; const isOverdue = row.type === 'invoice' && row.status === 'overdue'; const isIncoming = row.direction === 'incoming'; const day = row.scheduledDate?.split('-')[2] || '??'; const monthShort = row.scheduledDate ? new Date(row.scheduledDate + 'T12:00:00').toLocaleDateString('ru-RU', { month: 'short' }) : '—'; return (

{day}

{monthShort}

{isIncoming && } {row.isCash && }

{row.contractorName || '—'}

{row.type === 'invoice' && isPaid && } {row.probability && row.probability !== 'confirmed' && ( {probabilityLabel[row.probability] || row.probability} )}

{row.description || '—'}

{isIncoming ? '+' : ''} {row.amount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}{' '} {row.currency === 'RUB' || !row.currency ? '₽' : row.currency}

{!isPaid && !isIncoming && row.type === 'invoice' && (

Остаток после: {((currentBalance - row.amount) / 1000).toFixed(0)}k

)}
{row.type === 'entry' && row.entryId && ( )} {row.type === 'invoice' && (row.status !== 'paid' ? ( <> {row.paymentInvoiceId && ( <> )} ) : ( ))}
); })}
{showEntryForm && ( { setShowEntryForm(false); onRefreshCalendar?.(); }} onCancel={() => setShowEntryForm(false)} /> )} {statusModal && ( setStatusModal(null)} /> )}
); };