Files
mkd/components/office/OfficeSummary.tsx
2026-02-04 00:17:04 +05:00

832 lines
42 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 { authFetch } from '../../services/apiClient';
import { readCache, saveCache } from '../../hooks/useCachedFetch';
import { REFRESH_EVENTS } from '../../constants/refreshEvents';
import { ShoppingCart, Package, FileText, Banknote, Calendar, Activity, AlertTriangle, ShieldCheck, TrendingUp, Clock, CheckCircle, XCircle, Plus, Receipt, Wrench, X } from 'lucide-react';
import { OfficeRequest, OfficeEquipment, OfficeInventoryItem, OfficeDocument } from '../../types';
interface Props {
requests: OfficeRequest[];
}
export const OfficeSummary: React.FC<Props> = ({ requests }) => {
const CACHE_KEY = 'mkd_office_summary_cache';
const cached = readCache<{ equipment: OfficeEquipment[]; inventory: OfficeInventoryItem[]; documents: OfficeDocument[]; monthlyExpenses: number; officeInvoices: any[]; repairRequests: any[]; orders: any[] }>(CACHE_KEY, {
equipment: [], inventory: [], documents: [], monthlyExpenses: 0, officeInvoices: [], repairRequests: [], orders: []
});
const hasCache = cached.equipment?.length > 0 || cached.inventory?.length > 0 || cached.documents?.length > 0;
const [equipment, setEquipment] = useState<OfficeEquipment[]>(cached.equipment || []);
const [inventory, setInventory] = useState<OfficeInventoryItem[]>(cached.inventory || []);
const [documents, setDocuments] = useState<OfficeDocument[]>(cached.documents || []);
const [monthlyExpenses, setMonthlyExpenses] = useState<number>(cached.monthlyExpenses ?? 0);
const [officeInvoices, setOfficeInvoices] = useState<any[]>(cached.officeInvoices || []);
const [repairRequests, setRepairRequests] = useState<any[]>(cached.repairRequests || []);
const [orders, setOrders] = useState<any[]>(cached.orders || []);
const [loading, setLoading] = useState(!hasCache);
const [showCreateInvoiceModal, setShowCreateInvoiceModal] = useState(false);
// Заявок в работе (статусы: new, approved, ordered)
const pendingRequests = requests.filter(r =>
r.status === 'new' || r.status === 'approved' || r.status === 'ordered'
).length;
// Мало на складе
const lowStockItems = inventory.filter(i => {
const quantity = i.quantity || 0;
const minThreshold = i.minThreshold || 0;
return quantity <= minThreshold;
}).length;
// Входящие письма (не обработанные и не архивированные)
const incomingDocs = documents.filter(d =>
d.type === 'incoming' &&
d.status !== 'processed' &&
d.status !== 'archived'
).length;
useEffect(() => {
const run = async () => {
if (!hasCache) setLoading(true);
try {
await Promise.all([
fetchEquipment(),
fetchInventory(),
fetchDocuments(),
fetchMonthlyExpenses(),
fetchOfficeInvoices(),
fetchRepairRequests(),
fetchOrders()
]);
} finally {
setLoading(false);
}
};
run();
}, []);
const fetchAllData = async (showSpinner = true) => {
if (showSpinner) setLoading(true);
try {
await Promise.all([
fetchEquipment(),
fetchInventory(),
fetchDocuments(),
fetchMonthlyExpenses(),
fetchOfficeInvoices(),
fetchRepairRequests(),
fetchOrders()
]);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!loading && (equipment.length > 0 || inventory.length > 0 || documents.length > 0)) {
saveCache(CACHE_KEY, { equipment, inventory, documents, monthlyExpenses, officeInvoices, repairRequests, orders });
}
}, [loading, equipment, inventory, documents, monthlyExpenses, officeInvoices, repairRequests, orders]);
useEffect(() => {
const onRefresh = () => fetchAllData(false);
window.addEventListener(REFRESH_EVENTS.office, onRefresh);
return () => window.removeEventListener(REFRESH_EVENTS.office, onRefresh);
}, []);
useEffect(() => {
const interval = setInterval(() => fetchAllData(false), 10 * 1000);
return () => clearInterval(interval);
}, []);
const fetchEquipment = async () => {
try {
const response = await authFetch('/api/office/equipment');
if (response.ok) {
const data = await response.json();
// Нормализуем данные из API
const normalizedData = data.map((eq: any) => ({
id: eq.id,
name: eq.name || '',
type: eq.type || 'other',
brand: eq.brand,
model: eq.model,
serialNumber: eq.serial_number || eq.serialNumber,
assignedTo: eq.assigned_to || eq.assignedTo,
purchaseDate: eq.purchase_date || eq.purchaseDate,
warrantyUntil: eq.warranty_until || eq.warrantyUntil,
condition: eq.condition || 'good',
notes: eq.notes,
nextMaintenanceDate: eq.next_maintenance_date || eq.nextMaintenanceDate,
createdAt: eq.created_at || eq.createdAt,
updatedAt: eq.updated_at || eq.updatedAt
}));
setEquipment(normalizedData);
}
} catch (error) {
console.error('Ошибка загрузки оборудования:', error);
}
};
const fetchInventory = async () => {
try {
const response = await authFetch('/api/office/inventory');
if (response.ok) {
const data = await response.json();
// Нормализуем данные из API
const normalizedData = data.map((item: any) => ({
id: item.id,
itemName: item.item_name || item.itemName || '',
category: item.category || '',
quantity: item.quantity || 0,
unit: item.unit || 'шт',
minThreshold: item.min_threshold || item.minThreshold || 0,
location: item.location,
notes: item.notes,
createdAt: item.created_at || item.createdAt,
updatedAt: item.updated_at || item.updatedAt
}));
setInventory(normalizedData);
}
} catch (error) {
console.error('Ошибка загрузки инвентаря:', error);
}
};
const fetchDocuments = async () => {
try {
const response = await authFetch('/api/office/documents');
if (response.ok) {
const data = await response.json();
// Нормализуем данные из API
const normalizedData = data.map((doc: any) => ({
id: doc.id,
regNumber: doc.reg_number || doc.regNumber || '',
title: doc.title || '',
correspondent: doc.correspondent || '',
date: doc.date || '',
status: doc.status || 'registered',
type: doc.document_type || doc.type || 'incoming',
letterType: doc.letter_type || doc.letterType || 'paper',
assignedTo: doc.assigned_to || doc.assignedTo,
trackingNumber: doc.tracking_number || doc.trackingNumber,
fileUrl: doc.file_url || doc.fileUrl,
notes: doc.notes,
createdBy: doc.created_by || doc.createdBy || '',
createdAt: doc.created_at,
updatedAt: doc.updated_at
}));
setDocuments(normalizedData);
}
} catch (error) {
console.error('Ошибка загрузки документов:', error);
}
};
const fetchMonthlyExpenses = async () => {
try {
// Получаем заявки на закупки за текущий месяц со статусами ordered или received
const now = new Date();
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const lastDayOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const response = await authFetch('/api/office/supply-requests');
if (response.ok) {
const data = await response.json();
// Нормализуем данные
const normalizedData = data.map((req: any) => ({
id: req.id,
requesterName: req.requester_name || req.requesterName || '',
category: req.category || '',
itemName: req.item_name || req.itemName || '',
quantity: req.quantity || 0,
amount: parseFloat(req.amount) || 0, // Явно преобразуем в число
status: req.status || 'new',
createdAt: req.created_at || req.createdAt
}));
// Фильтруем заявки за текущий месяц со статусами ordered или received
const monthlyRequests = normalizedData.filter((req: OfficeRequest) => {
if (req.status !== 'ordered' && req.status !== 'received') return false;
if (!req.createdAt) return false;
const reqDate = new Date(req.createdAt);
return reqDate >= firstDayOfMonth && reqDate <= lastDayOfMonth;
});
// Суммируем затраты
const total = monthlyRequests.reduce((sum: number, req: OfficeRequest) => {
const amount = typeof req.amount === 'number' ? req.amount : parseFloat(String(req.amount)) || 0;
return sum + amount;
}, 0);
setMonthlyExpenses(isNaN(total) ? 0 : total);
}
} catch (error) {
console.error('Ошибка загрузки затрат:', error);
setMonthlyExpenses(0);
}
};
const fetchOfficeInvoices = async () => {
try {
// Получаем счета на оплату для офиса
const response = await authFetch('/api/finance/payment-invoices?purposeType=office&limit=50');
if (response.ok) {
const data = await response.json();
// API может возвращать массив или объект с полем invoices
const invoices = Array.isArray(data) ? data : (data.invoices || []);
setOfficeInvoices(invoices);
}
} catch (error) {
console.error('Ошибка загрузки счетов офиса:', error);
}
};
const fetchRepairRequests = async () => {
try {
// Получаем заявки на ремонт с платными
const response = await authFetch('/api/office/repair-requests');
if (response.ok) {
const data = await response.json();
// Фильтруем только платные ремонты
const paidRepairs = data.filter((req: any) => req.is_paid || req.isPaid);
setRepairRequests(paidRepairs);
}
} catch (error) {
console.error('Ошибка загрузки заявок на ремонт:', error);
}
};
const fetchOrders = async () => {
try {
// Получаем заказы
const response = await authFetch('/api/office/orders');
if (response.ok) {
const data = await response.json();
setOrders(data);
} else {
// Если endpoint еще не создан, используем пустой массив
setOrders([]);
}
} catch (error) {
console.error('Ошибка загрузки заказов:', error);
setOrders([]);
}
};
return (
<div className="space-y-6 animate-fade-in">
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm">
<div className="flex justify-between items-start mb-2">
<div className="p-2 bg-blue-50 text-blue-600 rounded-lg"><ShoppingCart className="w-5 h-5"/></div>
<span className="text-2xl font-black text-slate-800">{pendingRequests}</span>
</div>
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">Заявок в работе</p>
</div>
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm">
<div className="flex justify-between items-start mb-2">
<div className={`p-2 rounded-lg ${lowStockItems > 0 ? 'bg-red-50 text-red-600' : 'bg-emerald-50 text-emerald-600'}`}>
<Package className="w-5 h-5"/>
</div>
<span className={`text-2xl font-black ${lowStockItems > 0 ? 'text-red-600' : 'text-slate-800'}`}>{lowStockItems}</span>
</div>
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">Мало на складе</p>
</div>
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm">
<div className="flex justify-between items-start mb-2">
<div className="p-2 bg-amber-50 text-amber-600 rounded-lg"><FileText className="w-5 h-5"/></div>
<span className="text-2xl font-black text-slate-800">{incomingDocs}</span>
</div>
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">Входящие письма</p>
</div>
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm">
<div className="flex justify-between items-start mb-2">
<div className="p-2 bg-violet-50 text-violet-600 rounded-lg"><Banknote className="w-5 h-5"/></div>
<span className="text-2xl font-black text-slate-800">
{loading ? '...' : formatCurrency(monthlyExpenses)}
</span>
</div>
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">Затраты (мес)</p>
</div>
</div>
{/* Budget Visualization */}
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
<h3 className="font-bold text-slate-800 text-sm mb-6 flex items-center gap-2">
<Activity className="w-4 h-4 text-primary-500"/> Лимиты расходов офиса
</h3>
<div className="flex flex-col md:flex-row items-center gap-8">
<div className="w-40 h-40 rounded-full border-[16px] border-l-primary-500 border-t-emerald-400 border-r-amber-400 border-b-slate-100 box-border relative shadow-inner">
<div className="absolute inset-0 flex items-center justify-center flex-col">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-tighter">Остаток</span>
<span className="text-xl font-black text-slate-700">15.2к</span>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4 flex-1">
<LegendItem color="bg-primary-500" label="Канцелярия" value="30%" />
<LegendItem color="bg-emerald-400" label="Продукты" value="25%" />
<LegendItem color="bg-amber-400" label="Хоз. нужды" value="15%" />
<LegendItem color="bg-slate-200" label="Резерв" value="30%" />
</div>
</div>
</div>
{/* График обслуживания */}
<div className="bg-slate-900 rounded-2xl p-6 text-white shadow-xl relative overflow-hidden">
<ShieldCheck className="absolute top-0 right-0 p-8 w-40 h-40 opacity-10 rotate-12" />
<h3 className="text-lg font-bold mb-4 flex items-center gap-2 relative z-10">
<AlertTriangle className="w-5 h-5 text-amber-400"/> График обслуживания
</h3>
<div className="space-y-3 relative z-10">
{equipment
.filter(eq => eq.nextMaintenanceDate)
.sort((a, b) => new Date(a.nextMaintenanceDate!).getTime() - new Date(b.nextMaintenanceDate!).getTime())
.slice(0, 3)
.map(eq => {
const maintenanceDate = new Date(eq.nextMaintenanceDate!);
const today = new Date();
const daysLeft = Math.ceil((maintenanceDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
const isUrgent = daysLeft <= 0;
const dateStr = isUrgent ? 'Сегодня' : daysLeft === 1 ? 'Завтра' : maintenanceDate.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
return (
<div key={eq.id} className="flex justify-between items-center bg-white/10 backdrop-blur-md p-3 rounded-xl border border-white/10 hover:bg-white/20 transition-colors cursor-pointer">
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${isUrgent ? 'bg-red-500 animate-pulse' : 'bg-blue-400'}`}/>
<span className="text-xs font-bold text-slate-100">{eq.name}</span>
</div>
<span className={`text-[10px] font-black uppercase ${isUrgent ? 'text-red-400' : 'text-slate-400'}`}>
{dateStr}
</span>
</div>
);
})}
{equipment.filter(eq => eq.nextMaintenanceDate).length === 0 && (
<p className="text-xs text-slate-400">Нет запланированных ТО</p>
)}
</div>
</div>
{/* Dashboard заявок на закупку */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
<h3 className="font-bold text-slate-800 text-sm mb-6 flex items-center gap-2">
<ShoppingCart className="w-4 h-4 text-primary-500"/> Заявки на закупку
</h3>
{/* Статистика по статусам */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<StatCard
icon={<Clock className="w-4 h-4"/>}
label="Новые"
value={requests.filter(r => r.status === 'new').length}
color="bg-blue-50 text-blue-600"
/>
<StatCard
icon={<CheckCircle className="w-4 h-4"/>}
label="Одобренные"
value={requests.filter(r => r.status === 'approved').length}
color="bg-emerald-50 text-emerald-600"
/>
<StatCard
icon={<TrendingUp className="w-4 h-4"/>}
label="Заказано"
value={requests.filter(r => r.status === 'ordered').length}
color="bg-amber-50 text-amber-600"
/>
<StatCard
icon={<Package className="w-4 h-4"/>}
label="Получено"
value={requests.filter(r => r.status === 'received').length}
color="bg-violet-50 text-violet-600"
/>
</div>
{/* График распределения по категориям */}
<div className="mb-6">
<h4 className="text-xs font-bold text-slate-600 uppercase tracking-wider mb-3">По категориям</h4>
<div className="space-y-2">
{getCategoryStats(requests).map((stat, index) => (
<CategoryBar key={index} category={stat.category} count={stat.count} total={requests.length} />
))}
</div>
</div>
{/* Последние заявки */}
<div>
<h4 className="text-xs font-bold text-slate-600 uppercase tracking-wider mb-3">Последние заявки</h4>
<div className="space-y-2 max-h-64 overflow-y-auto">
{requests
.sort((a, b) => {
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA;
})
.slice(0, 5)
.map(req => (
<RequestItem key={req.id} request={req} />
))}
{requests.length === 0 && (
<p className="text-xs text-slate-400 text-center py-4">Нет заявок</p>
)}
</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 font-black text-[10px] text-slate-500 uppercase tracking-widest flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4"/> График платежей офиса
</div>
<button
onClick={() => setShowCreateInvoiceModal(true)}
className="px-3 py-1.5 bg-primary-600 text-white rounded-lg text-xs font-bold hover:bg-primary-700 transition-colors flex items-center gap-1"
>
<Plus className="w-3 h-3" />
Создать счет
</button>
</div>
<div className="divide-y divide-slate-100 max-h-96 overflow-y-auto">
{loading ? (
<div className="p-4 text-center text-xs text-slate-400">Загрузка...</div>
) : (
<>
{/* Счета из финансов */}
{officeInvoices.map((invoice: any) => (
<PaymentItem
key={`invoice-${invoice.id}`}
date={invoice.scheduledDate || invoice.scheduled_date || invoice.createdAt || invoice.created_at}
title={invoice.purposeDescription || invoice.purpose_description || 'Счет на оплату'}
amount={invoice.totalAmount || invoice.total_amount || 0}
status={invoice.status}
type="invoice"
invoiceId={invoice.id}
/>
))}
{/* Счета из заказов */}
{orders
.filter((order: any) => order.invoice_id || order.invoiceId || order.invoice_url || order.invoiceUrl)
.map((order: any) => (
<PaymentItem
key={`order-${order.id}`}
date={order.expected_date || order.expectedDate || order.created_at || order.createdAt}
title={`Заказ: ${order.title || order.order_number || order.orderNumber}`}
amount={order.total_amount || order.totalAmount || 0}
status={order.status === 'ordered' ? 'scheduled' : 'draft'}
type="order"
orderId={order.id}
/>
))}
{/* Платные ремонты */}
{repairRequests
.filter((req: any) => (req.is_paid || req.isPaid) && ((req.invoice_id || req.invoiceId) || (req.invoice_url || req.invoiceUrl)))
.map((req: any) => (
<PaymentItem
key={`repair-${req.id}`}
date={req.created_at || req.createdAt}
title={`Ремонт: ${req.equipment?.name || 'Оборудование'}`}
amount={req.cost || 0}
status={req.cost_estimated || req.costEstimated ? 'draft' : 'scheduled'}
type="repair"
repairId={req.id}
isEstimated={req.cost_estimated || req.costEstimated}
/>
))}
{officeInvoices.length === 0 &&
orders.filter((o: any) => o.invoiceId || o.invoiceUrl).length === 0 &&
repairRequests.filter((r: any) => (r.is_paid || r.isPaid) && (r.invoiceId || r.invoiceUrl)).length === 0 && (
<div className="p-4 text-center text-xs text-slate-400">Нет платежей</div>
)}
</>
)}
</div>
</div>
{/* Create Invoice Modal */}
{showCreateInvoiceModal && (
<CreateInvoiceModal
onClose={() => setShowCreateInvoiceModal(false)}
onSuccess={() => {
setShowCreateInvoiceModal(false);
fetchOfficeInvoices();
}}
/>
)}
</div>
);
};
const formatCurrency = (amount: number): string => {
// Проверяем, что amount - валидное число
if (isNaN(amount) || !isFinite(amount) || amount < 0) {
return '0';
}
// Округляем до целого
const rounded = Math.round(amount);
if (rounded >= 1000000) {
return `${(rounded / 1000000).toFixed(1)}млн`;
} else if (rounded >= 1000) {
return `${(rounded / 1000).toFixed(1)}к`;
}
return rounded.toString();
};
const LegendItem = ({ color, label, value }: any) => (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${color}`}/>
<span className="text-xs text-slate-600 font-medium">{label}</span>
</div>
<span className="text-xs font-black text-slate-800">{value}</span>
</div>
);
const PaymentItem = ({ date, title, amount, status, type, invoiceId, orderId, repairId, isEstimated }: any) => {
const dateObj = date ? new Date(date) : new Date();
const day = dateObj.getDate();
const month = dateObj.toLocaleDateString('ru-RU', { month: 'short' }).toUpperCase().slice(0, 3);
const getStatusColor = (status: string) => {
switch (status) {
case 'scheduled': return 'bg-blue-50 text-blue-600 border-blue-100';
case 'paid': return 'bg-emerald-50 text-emerald-600 border-emerald-100';
case 'approved': return 'bg-amber-50 text-amber-600 border-amber-100';
case 'draft': return 'bg-slate-50 text-slate-600 border-slate-100';
default: return 'bg-slate-50 text-slate-600 border-slate-100';
}
};
const getTypeIcon = () => {
switch (type) {
case 'invoice': return <Receipt className="w-4 h-4" />;
case 'order': return <Package className="w-4 h-4" />;
case 'repair': return <Wrench className="w-4 h-4" />;
default: return <FileText className="w-4 h-4" />;
}
};
return (
<div className="p-4 flex items-center gap-4 hover:bg-slate-50 transition-colors">
<div className={`bg-primary-50 text-primary-600 p-2 rounded-xl font-black text-center min-w-[50px] border border-primary-100`}>
<p className="text-sm leading-none">{day}</p>
<p className="text-[9px] uppercase mt-1">{month}</p>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{getTypeIcon()}
<p className="text-sm font-bold text-slate-800 truncate">{title}</p>
{isEstimated && (
<span className="text-[9px] font-bold text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">Предварительно</span>
)}
</div>
<div className="flex items-center gap-2">
<span className={`text-[9px] font-bold px-2 py-0.5 rounded border ${getStatusColor(status)}`}>
{status === 'scheduled' ? 'Запланировано' :
status === 'paid' ? 'Оплачено' :
status === 'approved' ? 'Одобрено' :
status === 'draft' ? 'Черновик' : status}
</span>
<span className="text-xs font-black text-slate-800">{formatCurrency(amount)}</span>
</div>
</div>
</div>
);
};
const CreateInvoiceModal = ({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) => {
const [formData, setFormData] = useState({
contractorName: '',
serviceDescription: '',
totalAmount: '',
paymentFormat: 'postpayment' as 'prepayment' | 'postpayment' | 'advance',
notes: ''
});
const handleSubmit = async () => {
try {
const response = await authFetch('/api/finance/payment-invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
createdBy: 'Current User', // TODO: получить из контекста
purposeType: 'office',
purposeDescription: formData.serviceDescription,
paymentFormat: formData.paymentFormat,
contractorName: formData.contractorName,
serviceItems: [{ name: formData.serviceDescription, amount: parseFloat(formData.totalAmount) || 0 }],
totalAmount: parseFloat(formData.totalAmount) || 0,
itemType: 'service',
notes: formData.notes
})
});
if (response.ok) {
window.dispatchEvent(new CustomEvent('mkd-office-changed'));
onSuccess();
alert('Счет успешно создан');
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка создания счета: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка создания счета:', error);
alert(`Ошибка создания счета: ${error.message || 'Неизвестная ошибка'}`);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-md w-full">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-slate-800">Создать счет на оплату</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-100 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Контрагент *</label>
<input
type="text"
value={formData.contractorName}
onChange={(e) => setFormData({ ...formData, contractorName: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="Название организации"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Описание услуги *</label>
<input
type="text"
value={formData.serviceDescription}
onChange={(e) => setFormData({ ...formData, serviceDescription: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="Например: Закупка канцелярии"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Сумма *</label>
<input
type="number"
value={formData.totalAmount}
onChange={(e) => setFormData({ ...formData, totalAmount: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="0"
min="0"
step="0.01"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Формат оплаты</label>
<select
value={formData.paymentFormat}
onChange={(e) => setFormData({ ...formData, paymentFormat: e.target.value as any })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="postpayment">Постоплата</option>
<option value="prepayment">Предоплата</option>
<option value="advance">Аванс</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Примечания</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={3}
placeholder="Дополнительная информация"
/>
</div>
<div className="flex gap-3 pt-4">
<button
onClick={handleSubmit}
disabled={!formData.contractorName || !formData.serviceDescription || !formData.totalAmount}
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
>
Создать
</button>
<button
onClick={onClose}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
>
Отмена
</button>
</div>
</div>
</div>
</div>
);
};
const StatCard = ({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: number; color: string }) => (
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
<div className="flex items-center justify-between mb-2">
<div className={`p-1.5 rounded-lg ${color}`}>
{icon}
</div>
<span className="text-xl font-black text-slate-800">{value}</span>
</div>
<p className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">{label}</p>
</div>
);
const getCategoryStats = (requests: OfficeRequest[]) => {
const categoryMap = new Map<string, number>();
requests.forEach(req => {
const category = req.category || 'Другое';
categoryMap.set(category, (categoryMap.get(category) || 0) + 1);
});
return Array.from(categoryMap.entries())
.map(([category, count]) => ({ category, count }))
.sort((a, b) => b.count - a.count);
};
const CategoryBar = ({ category, count, total }: { category: string; count: number; total: number }) => {
const percentage = total > 0 ? (count / total) * 100 : 0;
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-slate-700">{category}</span>
<span className="font-bold text-slate-800">{count} ({percentage.toFixed(0)}%)</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2 overflow-hidden">
<div
className="bg-primary-500 h-full rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
};
const RequestItem = ({ request }: { request: OfficeRequest }) => {
const getStatusColor = (status: string) => {
switch (status) {
case 'new': return 'bg-blue-100 text-blue-700';
case 'approved': return 'bg-emerald-100 text-emerald-700';
case 'ordered': return 'bg-amber-100 text-amber-700';
case 'received': return 'bg-violet-100 text-violet-700';
case 'collected': return 'bg-slate-100 text-slate-700';
case 'archived': return 'bg-gray-100 text-gray-700';
default: return 'bg-slate-100 text-slate-700';
}
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
new: 'Новая',
approved: 'Одобрена',
ordered: 'Заказано',
received: 'Получено',
collected: 'Забрано',
archived: 'Архив'
};
return labels[status] || status;
};
const dateStr = request.createdAt
? new Date(request.createdAt).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
: '—';
return (
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-lg border border-slate-200 hover:bg-slate-100 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-bold text-slate-800 truncate">{request.itemName || 'Без названия'}</span>
{request.amount && request.amount > 0 && (
<span className="text-xs font-black text-primary-600">{formatCurrency(request.amount)}</span>
)}
</div>
<div className="flex items-center gap-2 text-[10px] text-slate-500">
<span className="truncate">{request.category || 'Без категории'}</span>
<span></span>
<span>{request.requesterName || 'Не указан'}</span>
<span></span>
<span>{dateStr}</span>
</div>
</div>
<span className={`px-2 py-1 rounded text-[9px] font-bold uppercase ${getStatusColor(request.status || 'new')}`}>
{getStatusLabel(request.status || 'new')}
</span>
</div>
);
};