Files
mkd/components/finance/PaymentCalendar.tsx

516 lines
22 KiB
TypeScript
Raw Permalink Normal View History

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