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