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

294 lines
13 KiB
TypeScript
Executable File
Raw Permalink 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, useEffect } from 'react';
import { PaymentInvoice, PaymentInvoiceStatus, PaymentInvoicePurposeType, PaymentInvoiceFormat } from '../../types';
import { apiClient } from '../../services/apiClient';
import { FileText, Search, Filter, Eye, Edit2, CheckCircle2, XCircle, Clock, Calendar } from 'lucide-react';
interface PaymentInvoiceListProps {
onInvoiceClick: (invoice: PaymentInvoice) => void;
onCreateNew: () => void;
currentUserId: string;
/** Можно ли создавать/редактировать (по умолчанию true) */
canEdit?: boolean;
/** Показывать только счета на своё имя / от своего имени */
scopeOwn?: boolean;
}
const StatusBadge: React.FC<{ status: PaymentInvoiceStatus }> = ({ status }) => {
const config: Record<PaymentInvoiceStatus, { label: string; color: string; bg: string }> = {
draft: { label: 'Черновик', color: 'text-slate-500', bg: 'bg-slate-100' },
pending_manager_approval: { label: 'На согл. руков.', color: 'text-amber-600', bg: 'bg-amber-50' },
pending_finance_manager_approval: { label: 'На согл. фин. руков.', color: 'text-blue-600', bg: 'bg-blue-50' },
approved: { label: 'Согласован', color: 'text-indigo-600', bg: 'bg-indigo-50' },
scheduled: { label: 'В графике', color: 'text-purple-600', bg: 'bg-purple-50' },
paid: { label: 'Оплачен', color: 'text-emerald-600', bg: 'bg-emerald-50' },
postponed: { label: 'Отложен', color: 'text-orange-600', bg: 'bg-orange-50' },
cancelled: { label: 'Отменен', color: 'text-red-600', bg: 'bg-red-50' },
rejected: { label: 'Отклонен', color: 'text-red-600', bg: 'bg-red-50' },
completed: { label: 'Выполнено', color: 'text-green-600', bg: 'bg-green-50' }
};
const s = config[status] || config.draft;
return (
<span className={`px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider ${s.bg} ${s.color}`}>
{s.label}
</span>
);
};
const PurposeTypeLabel: Record<PaymentInvoicePurposeType, string> = {
building: 'Дом',
district: 'Участок',
legal: 'Юристы',
office: 'Офис',
hr: 'HR',
event: 'Мероприятие',
other: 'Другое'
};
const PaymentFormatLabel: Record<PaymentInvoiceFormat, string> = {
prepayment: 'Предоплата',
postpayment: 'Постоплата',
advance: 'Аванс'
};
export const PaymentInvoiceList: React.FC<PaymentInvoiceListProps> = ({
onInvoiceClick,
onCreateNew,
currentUserId,
canEdit = true,
scopeOwn = false,
}) => {
const [invoices, setInvoices] = useState<PaymentInvoice[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [purposeTypeFilter, setPurposeTypeFilter] = useState<string>('all');
const [paymentFormatFilter, setPaymentFormatFilter] = useState<string>('all');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
useEffect(() => {
fetchInvoices();
}, [statusFilter, purposeTypeFilter, paymentFormatFilter, search, page, scopeOwn]);
const fetchInvoices = async () => {
try {
setLoading(true);
const params = new URLSearchParams();
if (statusFilter !== 'all') params.append('status', statusFilter);
if (purposeTypeFilter !== 'all') params.append('purposeType', purposeTypeFilter);
if (paymentFormatFilter !== 'all') params.append('paymentFormat', paymentFormatFilter);
if (search) params.append('search', search);
if (scopeOwn) params.append('scope', 'own');
params.append('page', page.toString());
params.append('limit', '20');
const response = await apiClient.get<{ invoices: PaymentInvoice[]; pagination: any }>(
`/finance/payment-invoices?${params.toString()}`
);
setInvoices(response.invoices);
setTotalPages(response.pagination.totalPages);
} catch (err) {
console.error('Error fetching invoices:', err);
} finally {
setLoading(false);
}
};
const getPurposeLabel = (invoice: PaymentInvoice): string => {
if (invoice.purposeType === 'building' && invoice.purposeBuildingIds.length > 0) {
return `Дом (${invoice.purposeBuildingIds.length})`;
}
if (invoice.purposeType === 'district' && invoice.purposeDistrictIds.length > 0) {
return `Участок (${invoice.purposeDistrictIds.length})`;
}
if (invoice.purposeType === 'event') {
return invoice.purposeDescription || PurposeTypeLabel.event || 'Мероприятие';
}
return PurposeTypeLabel[invoice.purposeType] || invoice.purposeDescription || 'Не указано';
};
if (loading) {
return (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto mb-4"></div>
<p className="text-slate-600">Загрузка счетов...</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Фильтры и поиск */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex flex-col md:flex-row gap-4">
{/* Поиск */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Поиск по номеру, подрядчику, услуге..."
className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
{/* Фильтры */}
<div className="flex gap-2">
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
className="px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
>
<option value="all">Все статусы</option>
<option value="draft">Черновик</option>
<option value="pending_manager_approval">На согл. руков.</option>
<option value="pending_finance_manager_approval">На согл. фин. руков.</option>
<option value="approved">Согласован</option>
<option value="scheduled">В графике</option>
<option value="paid">Оплачен</option>
</select>
<select
value={purposeTypeFilter}
onChange={(e) => {
setPurposeTypeFilter(e.target.value);
setPage(1);
}}
className="px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
>
<option value="all">Все назначения</option>
<option value="building">Дом</option>
<option value="district">Участок</option>
<option value="legal">Юристы</option>
<option value="office">Офис</option>
<option value="hr">HR</option>
<option value="other">Другое</option>
</select>
<select
value={paymentFormatFilter}
onChange={(e) => {
setPaymentFormatFilter(e.target.value);
setPage(1);
}}
className="px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-sm"
>
<option value="all">Все форматы</option>
<option value="prepayment">Предоплата</option>
<option value="postpayment">Постоплата</option>
<option value="advance">Аванс</option>
</select>
</div>
</div>
</div>
{/* Список счетов */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-4 bg-slate-50 border-b border-slate-200 flex justify-between items-center">
<h3 className="font-black text-slate-700 text-sm uppercase tracking-widest">
Счета на оплату ({invoices.length})
</h3>
{canEdit && (
<button
onClick={onCreateNew}
className="px-3 py-1.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 text-xs font-bold transition-colors"
>
+ Новый счет
</button>
)}
</div>
<div className="divide-y divide-slate-100">
{invoices.length === 0 ? (
<div className="p-10 text-center text-slate-400 text-sm">Счетов не найдено</div>
) : (
invoices.map(invoice => (
<div
key={invoice.id}
onClick={() => onInvoiceClick(invoice)}
className="p-4 flex flex-col md:flex-row md:items-center justify-between hover:bg-slate-50 transition-colors cursor-pointer group gap-4"
>
<div className="flex gap-4 items-center flex-1 min-w-0">
<div className="p-3 rounded-xl bg-slate-50 text-slate-400 group-hover:bg-primary-50 group-hover:text-primary-600 transition-colors">
<FileText className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<StatusBadge status={invoice.status} />
<span className="text-xs text-slate-500 font-mono">{invoice.invoiceNumber}</span>
</div>
<p className="text-sm font-bold text-slate-800 truncate">{invoice.contractorName}</p>
<p className="text-[10px] text-slate-500 truncate">
{getPurposeLabel(invoice)} {PaymentFormatLabel[invoice.paymentFormat]} {invoice.itemType === 'materials' ? 'ТМЦ' : 'Услуга'} {invoice.serviceDescription}
</p>
</div>
</div>
<div className="flex items-center justify-between md:justify-end gap-6">
<div className="text-right">
<p className="text-sm font-black text-slate-900">
{invoice.totalAmount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
<p className="text-[10px] text-slate-400">
{new Date(invoice.createdAt).toLocaleDateString('ru-RU')}
</p>
{invoice.scheduledDate && (
<p className="text-[10px] text-slate-400 flex items-center gap-1">
<Calendar className="w-3 h-3" />
{new Date(invoice.scheduledDate).toLocaleDateString('ru-RU')}
</p>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
onInvoiceClick(invoice);
}}
className="p-2 bg-slate-100 text-slate-600 rounded-lg hover:bg-primary-100 hover:text-primary-600 transition-colors"
title="Просмотр"
>
<Eye className="w-4 h-4" />
</button>
</div>
</div>
))
)}
</div>
{/* Пагинация */}
{totalPages > 1 && (
<div className="p-4 border-t border-slate-200 flex justify-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1 border border-slate-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-50"
>
Назад
</button>
<span className="px-4 py-1 text-sm text-slate-600">
Страница {page} из {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-3 py-1 border border-slate-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-50"
>
Вперед
</button>
</div>
)}
</div>
</div>
);
};