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