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