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