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

516 lines
22 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 } 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<void>;
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<PaymentCalendarProps> = ({
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<Record<number, boolean>>({});
const [showEntryForm, setShowEntryForm] = useState(false);
const [defaultFormDirection, setDefaultFormDirection] = useState<PaymentDirection>('outgoing');
const [statusModal, setStatusModal] = useState<{ action: PaymentStatusAction; row: RowItem } | null>(null);
const [financeAccounts, setFinanceAccounts] = useState<FinanceAccount[]>([]);
const { dateFrom, dateTo, label } = useMemo(() => getPeriodBounds(interval), [interval]);
useEffect(() => {
const load = async () => {
try {
const list = await apiClient.get<FinanceAccount[]>('/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<string, string> = {
confirmed: '100%',
high: '80%',
medium: '50%',
low: '30%'
};
return (
<div className="space-y-6 animate-fade-in">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-2">
<div className="flex bg-slate-100 p-1 rounded-xl w-fit">
<button
onClick={() => setFilter('all')}
className={`px-4 py-1.5 text-xs font-bold rounded-lg transition-all ${filter === 'all' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
>
Все
</button>
<button
onClick={() => setFilter('planned')}
className={`px-4 py-1.5 text-xs font-bold rounded-lg transition-all ${filter === 'planned' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
>
План
</button>
<button
onClick={() => setFilter('paid')}
className={`px-4 py-1.5 text-xs font-bold rounded-lg transition-all ${filter === 'paid' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
>
Оплачено
</button>
</div>
<div className="flex bg-slate-100 p-1 rounded-xl w-fit">
<button
onClick={() => setDirectionFilter('all')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all ${directionFilter === 'all' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
>
Все
</button>
<button
onClick={() => setDirectionFilter('outgoing')}
className={`flex items-center gap-1 px-3 py-1.5 text-xs font-bold rounded-lg transition-all ${directionFilter === 'outgoing' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
>
<ArrowUpCircle className="w-3.5 h-3.5" /> Расходы
</button>
<button
onClick={() => setDirectionFilter('incoming')}
className={`flex items-center gap-1 px-3 py-1.5 text-xs font-bold rounded-lg transition-all ${directionFilter === 'incoming' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
>
<ArrowDownCircle className="w-3.5 h-3.5" /> Поступления
</button>
</div>
</div>
<div className="flex items-center gap-3">
{onIntervalChange && (
<div className="flex bg-slate-100 p-1 rounded-xl">
<button
onClick={() => onIntervalChange('week')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all ${interval === 'week' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
>
Неделя
</button>
<button
onClick={() => onIntervalChange('month')}
className={`px-3 py-1.5 text-xs font-bold rounded-lg transition-all ${interval === 'month' ? 'bg-white shadow text-slate-800' : 'text-slate-500'}`}
>
Месяц
</button>
</div>
)}
{financeAccounts.length > 0 ? (
<div className="flex flex-wrap items-center gap-4">
<div className="flex flex-wrap gap-3">
{financeAccounts.filter((a) => a.type === 'bank').map((a) => (
<div key={a.id} className="flex items-center gap-2 px-3 py-2 bg-slate-100 rounded-xl border border-slate-200">
<Landmark className="w-4 h-4 text-slate-500" />
<div>
<p className="text-[10px] text-slate-500 font-bold uppercase">{a.name}</p>
<p className="text-sm font-black text-slate-800">{Number(a.balance).toLocaleString('ru-RU')} </p>
</div>
</div>
))}
{financeAccounts.filter((a) => a.type === 'cash').map((a) => (
<div key={a.id} className="flex items-center gap-2 px-3 py-2 bg-amber-50 rounded-xl border border-amber-200">
<Banknote className="w-4 h-4 text-amber-600" />
<div>
<p className="text-[10px] text-amber-700 font-bold uppercase">{a.name}</p>
<p className="text-sm font-black text-amber-800">{Number(a.balance).toLocaleString('ru-RU')} </p>
</div>
</div>
))}
</div>
<p className="text-[10px] text-slate-400 font-black uppercase">Итого: {financeAccounts.reduce((s, a) => s + Number(a.balance), 0).toLocaleString('ru-RU')} </p>
</div>
) : (
<div className="text-right hidden sm:block">
<p className="text-[10px] text-slate-400 font-black uppercase tracking-widest">Текущий остаток</p>
<p className="text-sm font-black text-slate-800">{currentBalance.toLocaleString()} </p>
</div>
)}
<button
onClick={() => {
setDefaultFormDirection('outgoing');
setShowEntryForm(true);
}}
className="flex items-center gap-2 px-3 py-2 bg-slate-900 text-white rounded-xl text-xs font-bold hover:bg-slate-800 transition-colors"
>
<Plus className="w-4 h-4" /> Добавить
</button>
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-4 bg-slate-900 text-white flex flex-wrap justify-between items-center gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-white/10 rounded-lg">
<Calendar className="w-5 h-5" />
</div>
<div>
<h3 className="font-bold leading-none uppercase tracking-tighter">Платежный календарь</h3>
<p className="text-[10px] text-slate-400 mt-1 uppercase font-bold tracking-widest">{periodLabel}</p>
</div>
</div>
<div className="flex gap-6">
<div className="text-right">
<p className="text-lg font-black">{Number(scheduledTotalOutgoing).toLocaleString('ru-RU')} </p>
<p className="text-[10px] text-slate-400 uppercase font-bold">К оплате (план)</p>
</div>
<div className="text-right">
<p className="text-lg font-black text-emerald-300">+{Number(scheduledTotalIncoming).toLocaleString('ru-RU')} </p>
<p className="text-[10px] text-slate-400 uppercase font-bold">Ожидаемые поступления</p>
</div>
</div>
</div>
<div className="divide-y divide-slate-100">
{allRows.length === 0 && (
<div className="p-12 text-center text-slate-400 italic">Нет записей за выбранный период</div>
)}
{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 (
<div
key={row.id}
className={`p-4 flex items-center gap-4 hover:bg-slate-50 transition-colors group ${isPaid ? 'bg-slate-50/50' : ''}`}
>
<div
className={`p-2 rounded-xl text-center min-w-[50px] border ${
isPaid
? 'bg-emerald-50 border-emerald-100 text-emerald-600'
: isOverdue
? 'bg-red-50 border-red-100 text-red-600'
: isIncoming
? 'bg-emerald-50/50 border-emerald-100 text-emerald-700'
: 'bg-white border-slate-200 text-slate-500 shadow-sm'
}`}
>
<p className="text-xs font-black leading-none">{day}</p>
<p className="text-[9px] font-bold uppercase mt-1">{monthShort}</p>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
{isIncoming && <ArrowDownCircle className="w-3.5 h-3.5 text-emerald-500 shrink-0" />}
{row.isCash && <Banknote className="w-3.5 h-3.5 text-amber-600 shrink-0" title="Наличные" />}
<p
className={`text-sm font-bold truncate ${isPaid ? 'text-slate-500' : 'text-slate-800'}`}
>
{row.contractorName || '—'}
</p>
{row.type === 'invoice' && isPaid && <CheckCircle2 className="w-3.5 h-3.5 text-emerald-500" />}
{row.probability && row.probability !== 'confirmed' && (
<span className="text-[9px] font-bold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{probabilityLabel[row.probability] || row.probability}
</span>
)}
</div>
<p className="text-[10px] text-slate-400 truncate">{row.description || '—'}</p>
</div>
<div className="text-right flex items-center gap-4">
<div>
<p
className={`text-sm font-black ${isPaid ? 'text-slate-400' : isIncoming ? 'text-emerald-700' : 'text-slate-900'}`}
>
{isIncoming ? '+' : ''}
{row.amount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}{' '}
{row.currency === 'RUB' || !row.currency ? '₽' : row.currency}
</p>
{!isPaid && !isIncoming && row.type === 'invoice' && (
<p className="text-[8px] text-slate-400 font-bold uppercase">
Остаток после: {((currentBalance - row.amount) / 1000).toFixed(0)}k
</p>
)}
</div>
<div className="flex gap-1">
{row.type === 'entry' && row.entryId && (
<button
onClick={() => handleDeleteEntry(row.entryId!)}
className="p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
title="Удалить запись"
>
<XCircle className="w-4 h-4" />
</button>
)}
{row.type === 'invoice' &&
(row.status !== 'paid' ? (
<>
<button
onClick={() => handlePaymentActionClick(row, 'paid')}
disabled={loading[row.paymentInvoiceId!]}
className="p-2 bg-slate-900 text-white rounded-lg hover:bg-slate-800 disabled:opacity-50"
title="Подтвердить оплату"
>
<Wallet className="w-4 h-4" />
</button>
{row.paymentInvoiceId && (
<>
<button
onClick={() => handlePaymentActionClick(row, 'postponed')}
disabled={loading[row.paymentInvoiceId!]}
className="p-2 bg-orange-100 text-orange-600 rounded-lg hover:bg-orange-200 disabled:opacity-50"
title="Отложить"
>
<Pause className="w-4 h-4" />
</button>
<button
onClick={() => handlePaymentActionClick(row, 'cancelled')}
disabled={loading[row.paymentInvoiceId!]}
className="p-2 bg-red-100 text-red-600 rounded-lg hover:bg-red-200 disabled:opacity-50"
title="Отменить"
>
<XCircle className="w-4 h-4" />
</button>
</>
)}
</>
) : (
<button
onClick={() => handlePaymentActionClick(row, 'postponed')}
disabled={loading[row.paymentInvoiceId!]}
className="p-2 bg-white text-slate-300 rounded-lg hover:text-indigo-600 disabled:opacity-50"
title="Вернуть в план"
>
<History className="w-4 h-4" />
</button>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
{showEntryForm && (
<PaymentCalendarEntryForm
currentUserId={currentUserId}
defaultDirection={defaultFormDirection}
onSave={async () => {
setShowEntryForm(false);
onRefreshCalendar?.();
}}
onCancel={() => setShowEntryForm(false)}
/>
)}
{statusModal && (
<PaymentStatusModal
action={statusModal.action}
invoiceLabel={`${statusModal.row.contractorName}${statusModal.row.amount.toLocaleString('ru-RU')}`}
onConfirm={handleStatusModalConfirm}
onCancel={() => setStatusModal(null)}
/>
)}
</div>
);
};