730 lines
32 KiB
TypeScript
730 lines
32 KiB
TypeScript
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|||
|
|
import { PaymentInvoice, UserRole } from '../../types';
|
|||
|
|
import { apiClient } from '../../services/apiClient';
|
|||
|
|
import { ArrowLeft, Check, X, Calendar, Clock, FileText, Building2, MapPin, Briefcase, Users, Calculator, History, CheckCircle2, Paperclip, Download, PartyPopper, Wallet, Banknote } from 'lucide-react';
|
|||
|
|
import { PaymentStatusModal, PaymentStatusModalPayload } from './PaymentStatusModal';
|
|||
|
|
|
|||
|
|
interface PaymentInvoiceDetailProps {
|
|||
|
|
invoice: PaymentInvoice;
|
|||
|
|
currentUserId: string;
|
|||
|
|
onBack: () => void;
|
|||
|
|
onUpdate: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const StatusConfig: Record<PaymentInvoice['status'], { 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 PurposeTypeIcons: Record<PaymentInvoice['purposeType'], any> = {
|
|||
|
|
building: Building2,
|
|||
|
|
district: MapPin,
|
|||
|
|
legal: Briefcase,
|
|||
|
|
office: FileText,
|
|||
|
|
hr: Users,
|
|||
|
|
event: PartyPopper,
|
|||
|
|
other: FileText
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const PaymentInvoiceDetail: React.FC<PaymentInvoiceDetailProps> = ({
|
|||
|
|
invoice,
|
|||
|
|
currentUserId,
|
|||
|
|
onBack,
|
|||
|
|
onUpdate
|
|||
|
|
}) => {
|
|||
|
|
const [userRoles, setUserRoles] = useState<string[]>([]);
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
const [rejectionReason, setRejectionReason] = useState('');
|
|||
|
|
const [approvalComment, setApprovalComment] = useState('');
|
|||
|
|
const [scheduledDate, setScheduledDate] = useState(invoice.scheduledDate || '');
|
|||
|
|
const [closingDocsUpdating, setClosingDocsUpdating] = useState(false);
|
|||
|
|
const closingDocsFileInputRef = useRef<HTMLInputElement | null>(null);
|
|||
|
|
const [closingDocsUploading, setClosingDocsUploading] = useState(false);
|
|||
|
|
const [showPayModal, setShowPayModal] = useState<'default' | 'cash' | null>(null);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
fetchUserRoles();
|
|||
|
|
}, [currentUserId]);
|
|||
|
|
|
|||
|
|
const fetchUserRoles = async () => {
|
|||
|
|
try {
|
|||
|
|
const roles = await apiClient.get<UserRole[]>(`/finance/user-roles?userId=${currentUserId}`);
|
|||
|
|
setUserRoles(roles.map(r => r.role));
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error fetching user roles:', err);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const canApprove = () => {
|
|||
|
|
// Проверка прав на согласование
|
|||
|
|
const TOP_MANAGEMENT_ROLES = ['finance_director', 'director', 'top_management'];
|
|||
|
|
const statusConfig: Record<string, { role?: string }> = {
|
|||
|
|
pending_manager_approval: { role: 'manager' },
|
|||
|
|
pending_finance_manager_approval: { role: 'finance_manager' }
|
|||
|
|
};
|
|||
|
|
const config = statusConfig[invoice.status];
|
|||
|
|
if (!config || !config.role) return false;
|
|||
|
|
|
|||
|
|
// Если требуется роль manager, но пользователь из высшего звена - может согласовать
|
|||
|
|
if (config.role === 'manager' && userRoles.some(role => TOP_MANAGEMENT_ROLES.includes(role))) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return userRoles.includes(config.role);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const canSchedule = () => {
|
|||
|
|
// Ставить в график может финансист ИЛИ финансовый руководитель.
|
|||
|
|
// В демо, если ролей нет, считаем текущего пользователя финансовым блоком.
|
|||
|
|
const canScheduleRole =
|
|||
|
|
userRoles.includes('financier') ||
|
|||
|
|
userRoles.includes('finance_manager') ||
|
|||
|
|
userRoles.length === 0;
|
|||
|
|
|
|||
|
|
if (!canScheduleRole) return false;
|
|||
|
|
|
|||
|
|
// Для постоплаты можем поставить в график после согласования или после выполнения
|
|||
|
|
if (invoice.paymentFormat === 'postpayment') {
|
|||
|
|
return ['approved', 'completed'].includes(invoice.status);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Для предоплаты и аванса ставим в график первый платеж после согласования
|
|||
|
|
if (invoice.paymentFormat === 'prepayment' || invoice.paymentFormat === 'advance') {
|
|||
|
|
return invoice.status === 'approved';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return invoice.status === 'approved';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const canMarkCompleted = () => {
|
|||
|
|
if (invoice.paymentFormat === 'postpayment') {
|
|||
|
|
return ['approved', 'scheduled'].includes(invoice.status) && !invoice.isCompleted;
|
|||
|
|
} else if (invoice.paymentFormat === 'prepayment' || invoice.paymentFormat === 'advance') {
|
|||
|
|
return invoice.status === 'paid' && !invoice.isCompleted;
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleApprove = async () => {
|
|||
|
|
try {
|
|||
|
|
setLoading(true);
|
|||
|
|
await apiClient.post(`/finance/payment-invoices/${invoice.id}/approve`, {
|
|||
|
|
userId: currentUserId,
|
|||
|
|
comment: approvalComment
|
|||
|
|
});
|
|||
|
|
setApprovalComment('');
|
|||
|
|
onUpdate();
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error approving invoice:', err);
|
|||
|
|
alert('Ошибка при согласовании счета');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleReject = async () => {
|
|||
|
|
if (!rejectionReason.trim()) {
|
|||
|
|
alert('Укажите причину отклонения');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
setLoading(true);
|
|||
|
|
await apiClient.post(`/finance/payment-invoices/${invoice.id}/reject`, {
|
|||
|
|
userId: currentUserId,
|
|||
|
|
reason: rejectionReason
|
|||
|
|
});
|
|||
|
|
setRejectionReason('');
|
|||
|
|
onUpdate();
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error rejecting invoice:', err);
|
|||
|
|
alert('Ошибка при отклонении счета');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleSchedule = async () => {
|
|||
|
|
if (!scheduledDate) {
|
|||
|
|
alert('Укажите дату для графика платежей');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
setLoading(true);
|
|||
|
|
await apiClient.post(`/finance/payment-invoices/${invoice.id}/schedule`, {
|
|||
|
|
userId: currentUserId,
|
|||
|
|
scheduledDate
|
|||
|
|
});
|
|||
|
|
onUpdate();
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error scheduling invoice:', err);
|
|||
|
|
alert('Ошибка при постановке в график');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleMarkCompleted = async () => {
|
|||
|
|
try {
|
|||
|
|
setLoading(true);
|
|||
|
|
await apiClient.post(`/finance/payment-invoices/${invoice.id}/mark-completed`);
|
|||
|
|
onUpdate();
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error marking as completed:', err);
|
|||
|
|
alert('Ошибка при отметке выполнения');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleClosingDocsReceived = async () => {
|
|||
|
|
try {
|
|||
|
|
setClosingDocsUpdating(true);
|
|||
|
|
await apiClient.post(`/finance/payment-invoices/${invoice.id}/closing-docs`, {
|
|||
|
|
received: true
|
|||
|
|
});
|
|||
|
|
onUpdate();
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error updating closing docs flag:', err);
|
|||
|
|
alert('Ошибка при отметке закрывающих документов');
|
|||
|
|
} finally {
|
|||
|
|
setClosingDocsUpdating(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleMarkPaid = async (payload: PaymentStatusModalPayload) => {
|
|||
|
|
try {
|
|||
|
|
setLoading(true);
|
|||
|
|
await apiClient.post(`/finance/payment-invoices/${invoice.id}/update-payment-status`, {
|
|||
|
|
status: 'paid',
|
|||
|
|
paymentDate: payload.paymentDate,
|
|||
|
|
paymentRef: payload.paymentRef,
|
|||
|
|
isCash: payload.isCash
|
|||
|
|
});
|
|||
|
|
setShowPayModal(null);
|
|||
|
|
onUpdate();
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error marking invoice as paid:', err);
|
|||
|
|
alert('Ошибка при отметке оплаты');
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleClosingDocsFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|||
|
|
const file = e.target.files?.[0];
|
|||
|
|
if (!file) return;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
setClosingDocsUploading(true);
|
|||
|
|
|
|||
|
|
// Загружаем файл на сервер (как для обычных счетов)
|
|||
|
|
const formData = new FormData();
|
|||
|
|
formData.append('file', file);
|
|||
|
|
|
|||
|
|
const uploadResponse = await fetch('/api/finance/payment-invoices/upload', {
|
|||
|
|
method: 'POST',
|
|||
|
|
body: formData
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!uploadResponse.ok) {
|
|||
|
|
throw new Error('Ошибка загрузки файла');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const data = await uploadResponse.json();
|
|||
|
|
const fileInfo = data.file;
|
|||
|
|
|
|||
|
|
// Привязываем файл к счету как закрывающий документ и помечаем, что закрывашки получены
|
|||
|
|
await apiClient.post(`/finance/payment-invoices/${invoice.id}/closing-docs`, {
|
|||
|
|
received: true,
|
|||
|
|
files: [fileInfo]
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
onUpdate();
|
|||
|
|
} catch (err) {
|
|||
|
|
console.error('Error uploading closing docs file:', err);
|
|||
|
|
alert('Ошибка при загрузке файла закрывающих документов');
|
|||
|
|
} finally {
|
|||
|
|
setClosingDocsUploading(false);
|
|||
|
|
if (closingDocsFileInputRef.current) {
|
|||
|
|
closingDocsFileInputRef.current.value = '';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const PurposeIcon = PurposeTypeIcons[invoice.purposeType] || FileText;
|
|||
|
|
const statusInfo = StatusConfig[invoice.status];
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-6 animate-fade-in">
|
|||
|
|
{/* Заголовок */}
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<button
|
|||
|
|
onClick={onBack}
|
|||
|
|
className="flex items-center gap-2 text-primary-600 hover:text-primary-700 font-medium"
|
|||
|
|
>
|
|||
|
|
<ArrowLeft className="w-4 h-4" />
|
|||
|
|
Назад к списку
|
|||
|
|
</button>
|
|||
|
|
<div className={`px-3 py-1 rounded-lg ${statusInfo.bg} ${statusInfo.color}`}>
|
|||
|
|
<span className="text-sm font-bold">{statusInfo.label}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Основная информация */}
|
|||
|
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
|||
|
|
<div className="flex items-start justify-between mb-6">
|
|||
|
|
<div>
|
|||
|
|
<h2 className="text-xl font-bold text-slate-800 mb-1">Счет № {invoice.invoiceNumber}</h2>
|
|||
|
|
<p className="text-sm text-slate-500">
|
|||
|
|
Создан {new Date(invoice.createdAt).toLocaleDateString('ru-RU')} пользователем {invoice.createdBy}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|||
|
|
{/* Назначение */}
|
|||
|
|
<div>
|
|||
|
|
<h3 className="text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
|
|||
|
|
<PurposeIcon className="w-4 h-4" />
|
|||
|
|
Назначение
|
|||
|
|
</h3>
|
|||
|
|
<p className="text-slate-800">
|
|||
|
|
{invoice.purposeType === 'building' && invoice.purposeBuildingIds.length > 0 && (
|
|||
|
|
<>Дом ({invoice.purposeBuildingIds.length} шт.)</>
|
|||
|
|
)}
|
|||
|
|
{invoice.purposeType === 'district' && invoice.purposeDistrictIds.length > 0 && (
|
|||
|
|
<>Участок ({invoice.purposeDistrictIds.length} шт.)</>
|
|||
|
|
)}
|
|||
|
|
{['legal', 'office', 'hr'].includes(invoice.purposeType) && (
|
|||
|
|
<>{invoice.purposeType === 'legal' ? 'Юристы' : invoice.purposeType === 'office' ? 'Офис' : 'HR'}</>
|
|||
|
|
)}
|
|||
|
|
{invoice.purposeType === 'event' && (invoice.purposeDescription || 'Мероприятие')}
|
|||
|
|
{invoice.purposeType === 'other' && invoice.purposeDescription}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Формат оплаты */}
|
|||
|
|
<div>
|
|||
|
|
<h3 className="text-sm font-bold text-slate-700 mb-2 flex items-center gap-2">
|
|||
|
|
<Calculator className="w-4 h-4" />
|
|||
|
|
Формат оплаты
|
|||
|
|
</h3>
|
|||
|
|
<p className="text-slate-800">
|
|||
|
|
{invoice.paymentFormat === 'prepayment' && 'Предоплата'}
|
|||
|
|
{invoice.paymentFormat === 'postpayment' && 'Постоплата'}
|
|||
|
|
{invoice.paymentFormat === 'advance' && 'Аванс'}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Подрядчик */}
|
|||
|
|
<div>
|
|||
|
|
<h3 className="text-sm font-bold text-slate-700 mb-2">Подрядчик</h3>
|
|||
|
|
<p className="text-slate-800 font-medium">{invoice.contractorName}</p>
|
|||
|
|
{invoice.contractorInn && (
|
|||
|
|
<p className="text-sm text-slate-500">ИНН: {invoice.contractorInn}</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Сумма */}
|
|||
|
|
<div>
|
|||
|
|
<h3 className="text-sm font-bold text-slate-700 mb-2">Сумма</h3>
|
|||
|
|
<p className="text-2xl font-black text-slate-900">
|
|||
|
|
{invoice.totalAmount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Услуги или ТМЦ */}
|
|||
|
|
<div className="md:col-span-2">
|
|||
|
|
<h3 className="text-sm font-bold text-slate-700 mb-2">
|
|||
|
|
{invoice.itemType === 'service' ? 'Услуги' : 'ТМЦ (товарно-материальные ценности)'}
|
|||
|
|
</h3>
|
|||
|
|
|
|||
|
|
{invoice.itemType === 'service' && invoice.serviceItems && invoice.serviceItems.length > 0 ? (
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{invoice.serviceItems.map((item, index) => (
|
|||
|
|
<div key={index} className="flex justify-between items-center p-2 bg-slate-50 rounded-lg">
|
|||
|
|
<span className="text-slate-800 font-medium">{item.name}</span>
|
|||
|
|
<span className="text-slate-900 font-bold">
|
|||
|
|
{item.amount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
) : invoice.itemType === 'materials' && invoice.materialItems && invoice.materialItems.length > 0 ? (
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{invoice.materialItems.map((item, index) => (
|
|||
|
|
<div key={index} className="p-2 bg-slate-50 rounded-lg">
|
|||
|
|
<div className="flex justify-between items-center mb-1">
|
|||
|
|
<span className="text-slate-800 font-medium">{item.name}</span>
|
|||
|
|
<span className="text-slate-900 font-bold">
|
|||
|
|
{item.amount.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="text-xs text-slate-500">
|
|||
|
|
{item.quantity} {item.unit} × {item.pricePerUnit.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<p className="text-slate-800">{invoice.serviceDescription || 'Нет данных'}</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Даты */}
|
|||
|
|
<div className="mt-6 pt-6 border-t border-slate-200 grid grid-cols-1 md:grid-cols-3 gap-4">
|
|||
|
|
{invoice.scheduledDate && (
|
|||
|
|
<div>
|
|||
|
|
<p className="text-xs text-slate-500 mb-1">Дата в графике</p>
|
|||
|
|
<p className="text-sm font-medium text-slate-800">
|
|||
|
|
{new Date(invoice.scheduledDate).toLocaleDateString('ru-RU')}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{invoice.paymentDate && (
|
|||
|
|
<div>
|
|||
|
|
<p className="text-xs text-slate-500 mb-1">Дата оплаты</p>
|
|||
|
|
<p className="text-sm font-medium text-slate-800">
|
|||
|
|
{new Date(invoice.paymentDate).toLocaleDateString('ru-RU')}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{invoice.status === 'paid' && invoice.paymentRef && (
|
|||
|
|
<div>
|
|||
|
|
<p className="text-xs text-slate-500 mb-1">Номер платежки</p>
|
|||
|
|
<p className="text-sm font-medium text-slate-800">{invoice.paymentRef}</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{invoice.status === 'paid' && invoice.isCash && (
|
|||
|
|
<div>
|
|||
|
|
<p className="text-xs text-slate-500 mb-1">Способ оплаты</p>
|
|||
|
|
<p className="text-sm font-medium text-amber-700 flex items-center gap-1">
|
|||
|
|
<Banknote className="w-4 h-4" /> Оплата наличными
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{invoice.isCompleted && (
|
|||
|
|
<div>
|
|||
|
|
<p className="text-xs text-slate-500 mb-1">Статус выполнения</p>
|
|||
|
|
<p className="text-sm font-medium text-emerald-600 flex items-center gap-1">
|
|||
|
|
<CheckCircle2 className="w-4 h-4" />
|
|||
|
|
Выполнено
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{(invoice.paymentFormat === 'prepayment' || invoice.paymentFormat === 'advance') && (
|
|||
|
|
<div>
|
|||
|
|
<p className="text-xs text-slate-500 mb-1">Закрывающие документы</p>
|
|||
|
|
<p className="text-sm font-medium text-slate-800">
|
|||
|
|
{invoice.closingDocsReceived ? 'Получены' : 'Не получены'}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Загруженные файлы (физические счета) */}
|
|||
|
|
{invoice.fileUrls && invoice.fileUrls.length > 0 && (
|
|||
|
|
<div className="mt-6 pt-6 border-t border-slate-200">
|
|||
|
|
<h3 className="text-sm font-bold text-slate-700 mb-4 flex items-center gap-2">
|
|||
|
|
<Paperclip className="w-4 h-4" />
|
|||
|
|
Физические счета
|
|||
|
|
</h3>
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{invoice.fileUrls.map((fileUrl, index) => {
|
|||
|
|
// fileUrl может быть строкой или объектом с информацией о файле
|
|||
|
|
const fileInfo = typeof fileUrl === 'string'
|
|||
|
|
? { url: fileUrl, filename: fileUrl.split('/').pop() || 'Файл' }
|
|||
|
|
: fileUrl;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div key={index} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border border-slate-200">
|
|||
|
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|||
|
|
<FileText className="w-4 h-4 text-slate-500 flex-shrink-0" />
|
|||
|
|
<span className="text-sm text-slate-700 truncate" title={fileInfo.filename || fileInfo.url}>
|
|||
|
|
{fileInfo.filename || fileInfo.url}
|
|||
|
|
</span>
|
|||
|
|
{fileInfo.size && (
|
|||
|
|
<span className="text-xs text-slate-500 flex-shrink-0">
|
|||
|
|
({(fileInfo.size / 1024).toFixed(1)} KB)
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<a
|
|||
|
|
href={fileInfo.url}
|
|||
|
|
target="_blank"
|
|||
|
|
rel="noopener noreferrer"
|
|||
|
|
className="flex items-center gap-1 px-3 py-1.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium"
|
|||
|
|
>
|
|||
|
|
<Download className="w-4 h-4" />
|
|||
|
|
Скачать
|
|||
|
|
</a>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Файлы закрывающих документов (сканы/фото) */}
|
|||
|
|
{invoice.closingDocsFiles && invoice.closingDocsFiles.length > 0 && (
|
|||
|
|
<div className="mt-6 pt-6 border-t border-slate-200">
|
|||
|
|
<h3 className="text-sm font-bold text-slate-700 mb-4 flex items-center gap-2">
|
|||
|
|
<Paperclip className="w-4 h-4" />
|
|||
|
|
Закрывающие документы
|
|||
|
|
</h3>
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{invoice.closingDocsFiles.map((file: any, index: number) => {
|
|||
|
|
const fileInfo = file || {};
|
|||
|
|
return (
|
|||
|
|
<div key={index} className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border border-slate-200">
|
|||
|
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|||
|
|
<FileText className="w-4 h-4 text-slate-500 flex-shrink-0" />
|
|||
|
|
<span className="text-sm text-slate-700 truncate" title={fileInfo.filename || fileInfo.url}>
|
|||
|
|
{fileInfo.filename || fileInfo.url || 'Файл'}
|
|||
|
|
</span>
|
|||
|
|
{fileInfo.size && (
|
|||
|
|
<span className="text-xs text-slate-500 flex-shrink-0">
|
|||
|
|
({(fileInfo.size / 1024).toFixed(1)} KB)
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
{fileInfo.url && (
|
|||
|
|
<a
|
|||
|
|
href={fileInfo.url}
|
|||
|
|
target="_blank"
|
|||
|
|
rel="noopener noreferrer"
|
|||
|
|
className="flex items-center gap-1 px-3 py-1.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors text-sm font-medium"
|
|||
|
|
>
|
|||
|
|
<Download className="w-4 h-4" />
|
|||
|
|
Скачать
|
|||
|
|
</a>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Примечания */}
|
|||
|
|
{invoice.notes && (
|
|||
|
|
<div className="mt-6 pt-6 border-t border-slate-200">
|
|||
|
|
<h3 className="text-sm font-bold text-slate-700 mb-2">Примечания</h3>
|
|||
|
|
<p className="text-slate-800">{invoice.notes}</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* История согласований */}
|
|||
|
|
{invoice.approvalHistory && invoice.approvalHistory.length > 0 && (
|
|||
|
|
<div className="mt-6 pt-6 border-t border-slate-200">
|
|||
|
|
<h3 className="text-sm font-bold text-slate-700 mb-4 flex items-center gap-2">
|
|||
|
|
<History className="w-4 h-4" />
|
|||
|
|
История согласований
|
|||
|
|
</h3>
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
{invoice.approvalHistory.map((entry, index) => (
|
|||
|
|
<div key={index} className="flex items-start gap-3 p-3 bg-slate-50 rounded-lg">
|
|||
|
|
<div className={`p-1.5 rounded ${
|
|||
|
|
entry.action === 'approve' ? 'bg-emerald-100 text-emerald-600' :
|
|||
|
|
entry.action === 'reject' ? 'bg-red-100 text-red-600' :
|
|||
|
|
'bg-amber-100 text-amber-600'
|
|||
|
|
}`}>
|
|||
|
|
{entry.action === 'approve' ? (
|
|||
|
|
<Check className="w-4 h-4" />
|
|||
|
|
) : entry.action === 'reject' ? (
|
|||
|
|
<X className="w-4 h-4" />
|
|||
|
|
) : (
|
|||
|
|
<Clock className="w-4 h-4" />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<div className="flex-1">
|
|||
|
|
<p className="text-sm font-medium text-slate-800">
|
|||
|
|
{entry.action === 'approve' ? 'Согласовано' :
|
|||
|
|
entry.action === 'reject' ? 'Отклонено' : 'На доработку'}
|
|||
|
|
</p>
|
|||
|
|
<p className="text-xs text-slate-500">
|
|||
|
|
{entry.userRole} • {new Date(entry.date).toLocaleString('ru-RU')}
|
|||
|
|
</p>
|
|||
|
|
{entry.comment && (
|
|||
|
|
<p className="text-sm text-slate-700 mt-1">{entry.comment}</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Причина отклонения */}
|
|||
|
|
{invoice.status === 'rejected' && invoice.rejectionReason && (
|
|||
|
|
<div className="mt-6 pt-6 border-t border-slate-200">
|
|||
|
|
<h3 className="text-sm font-bold text-red-700 mb-2">Причина отклонения</h3>
|
|||
|
|
<p className="text-red-600">{invoice.rejectionReason}</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Причина отмены */}
|
|||
|
|
{invoice.status === 'cancelled' && invoice.cancelReason && (
|
|||
|
|
<div className="mt-6 pt-6 border-t border-slate-200">
|
|||
|
|
<h3 className="text-sm font-bold text-slate-700 mb-2">Причина отмены</h3>
|
|||
|
|
<p className="text-slate-600">{invoice.cancelReason}</p>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Действия */}
|
|||
|
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
|||
|
|
<h3 className="text-sm font-bold text-slate-700 mb-4">Действия</h3>
|
|||
|
|
|
|||
|
|
{canApprove() && (
|
|||
|
|
<div className="space-y-4 mb-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">Комментарий (необязательно)</label>
|
|||
|
|
<textarea
|
|||
|
|
value={approvalComment}
|
|||
|
|
onChange={(e) => setApprovalComment(e.target.value)}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|||
|
|
rows={2}
|
|||
|
|
placeholder="Добавьте комментарий..."
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex gap-3">
|
|||
|
|
<button
|
|||
|
|
onClick={handleApprove}
|
|||
|
|
disabled={loading}
|
|||
|
|
className="flex-1 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|||
|
|
>
|
|||
|
|
<Check className="w-4 h-4" />
|
|||
|
|
Согласовать
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => {
|
|||
|
|
const reason = prompt('Укажите причину отклонения:');
|
|||
|
|
if (reason) {
|
|||
|
|
setRejectionReason(reason);
|
|||
|
|
setTimeout(() => handleReject(), 100);
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
disabled={loading}
|
|||
|
|
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|||
|
|
>
|
|||
|
|
<X className="w-4 h-4" />
|
|||
|
|
Отклонить
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{canSchedule() && (
|
|||
|
|
<div className="space-y-4 mb-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">Дата в графике платежей *</label>
|
|||
|
|
<input
|
|||
|
|
type="date"
|
|||
|
|
value={scheduledDate}
|
|||
|
|
onChange={(e) => setScheduledDate(e.target.value)}
|
|||
|
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
onClick={handleSchedule}
|
|||
|
|
disabled={loading || !scheduledDate}
|
|||
|
|
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|||
|
|
>
|
|||
|
|
<Calendar className="w-4 h-4" />
|
|||
|
|
Поставить в график платежей
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{invoice.status === 'scheduled' && (
|
|||
|
|
<div className="space-y-2 mb-4">
|
|||
|
|
<button
|
|||
|
|
onClick={() => setShowPayModal('default')}
|
|||
|
|
disabled={loading}
|
|||
|
|
className="w-full px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|||
|
|
>
|
|||
|
|
<Wallet className="w-4 h-4" />
|
|||
|
|
Оплатить
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
onClick={() => setShowPayModal('cash')}
|
|||
|
|
disabled={loading}
|
|||
|
|
className="w-full px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|||
|
|
>
|
|||
|
|
<Banknote className="w-4 h-4" />
|
|||
|
|
Оплата наличкой
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{canMarkCompleted() && (
|
|||
|
|
<button
|
|||
|
|
onClick={handleMarkCompleted}
|
|||
|
|
disabled={loading}
|
|||
|
|
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|||
|
|
>
|
|||
|
|
<CheckCircle2 className="w-4 h-4" />
|
|||
|
|
{invoice.itemType === 'materials' ? 'Отметить полученным (ТМЦ)' : 'Отметить выполненным'}
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{(invoice.paymentFormat === 'prepayment' || invoice.paymentFormat === 'advance') &&
|
|||
|
|
invoice.isCompleted && (
|
|||
|
|
<div className="mt-4 space-y-3">
|
|||
|
|
{!invoice.closingDocsReceived && (
|
|||
|
|
<button
|
|||
|
|
onClick={handleClosingDocsReceived}
|
|||
|
|
disabled={closingDocsUpdating}
|
|||
|
|
className="w-full px-4 py-2 bg-sky-600 text-white rounded-lg hover:bg-sky-700 disabled:bg-slate-300 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|||
|
|
>
|
|||
|
|
<Check className="w-4 h-4" />
|
|||
|
|
Подтвердить получение закрывающих документов
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="flex flex-col sm:flex-row gap-2 items-stretch sm:items-center">
|
|||
|
|
<input
|
|||
|
|
ref={closingDocsFileInputRef}
|
|||
|
|
type="file"
|
|||
|
|
className="hidden"
|
|||
|
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif,.webp,.txt,.zip,.rar"
|
|||
|
|
onChange={handleClosingDocsFileChange}
|
|||
|
|
/>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => closingDocsFileInputRef.current?.click()}
|
|||
|
|
disabled={closingDocsUploading}
|
|||
|
|
className="flex-1 px-4 py-2 border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 disabled:bg-slate-100 disabled:text-slate-400 flex items-center justify-center gap-2 text-sm"
|
|||
|
|
>
|
|||
|
|
<Paperclip className="w-4 h-4" />
|
|||
|
|
{closingDocsUploading ? 'Загрузка файла...' : 'Прикрепить скан/фото закрывающих'}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{showPayModal !== null && (
|
|||
|
|
<PaymentStatusModal
|
|||
|
|
action="paid"
|
|||
|
|
invoiceLabel={`${invoice.contractorName} — ${invoice.totalAmount.toLocaleString('ru-RU')} ₽`}
|
|||
|
|
defaultIsCash={showPayModal === 'cash'}
|
|||
|
|
onConfirm={handleMarkPaid}
|
|||
|
|
onCancel={() => setShowPayModal(null)}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|