Files
mkd/components/finance/PaymentInvoiceList.tsx

294 lines
13 KiB
TypeScript
Raw Normal View History

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