feat: add CompletedOrdersTable with sorting and CSV export

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 20:30:53 +03:00
parent 99c6624af7
commit 5714f99cbe
@@ -0,0 +1,153 @@
import React, { useState, useEffect } from 'react';
import { ArrowUpDown, Download } from 'lucide-react';
import { supabase } from '../../lib/supabase';
const OBJECT_TYPE_LABELS = {
industrial: 'Промышленный', commercial: 'Коммерческий',
infrastructure: 'Инфраструктура', residential: 'Частный', other: 'Другое',
};
const COLUMNS = [
{ key: 'closed_at', label: 'Дата закрытия' },
{ key: 'name', label: 'Клиент' },
{ key: 'object_type', label: 'Тип объекта' },
{ key: 'area_sqm', label: 'Площадь (м²)' },
{ key: 'final_cost', label: 'Итоговая стоимость (₽)' },
{ key: 'assigned_to', label: 'Ответственный' },
];
const CompletedOrdersTable = () => {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [sortKey, setSortKey] = useState('closed_at');
const [sortDir, setSortDir] = useState('desc');
useEffect(() => {
supabase
.from('orders')
.select('*')
.eq('status', 'закрыт')
.order('closed_at', { ascending: false })
.then(({ data }) => {
setOrders(data || []);
setLoading(false);
});
}, []);
const handleSort = (key) => {
if (sortKey === key) {
setSortDir((d) => d === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortDir('asc');
}
};
const sorted = [...orders].sort((a, b) => {
const av = a[sortKey] ?? '';
const bv = b[sortKey] ?? '';
if (av < bv) return sortDir === 'asc' ? -1 : 1;
if (av > bv) return sortDir === 'asc' ? 1 : -1;
return 0;
});
const exportCsv = () => {
const header = COLUMNS.map((c) => c.label).join(',');
const rows = sorted.map((o) => [
o.closed_at ? new Date(o.closed_at).toLocaleDateString('ru-RU') : '',
`"${(o.name || '').replace(/"/g, '""')}"`,
OBJECT_TYPE_LABELS[o.object_type] || o.object_type || '',
o.area_sqm ?? '',
o.final_cost ?? '',
`"${(o.assigned_to || '').replace(/"/g, '""')}"`,
].join(','));
const csv = [header, ...rows].join('\n');
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `completed_orders_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const thStyle = (key) => ({
padding: '10px 14px', textAlign: 'left',
fontSize: '0.75rem', fontWeight: 700,
color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px',
cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap',
background: sortKey === key ? 'rgba(14, 165, 233, 0.05)' : 'transparent',
});
const tdStyle = {
padding: '11px 14px', fontSize: '0.88rem',
color: 'var(--text-main)', borderTop: '1px solid var(--border-light)',
whiteSpace: 'nowrap',
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '18px' }}>
<h2 style={{ fontFamily: 'var(--font-heading)', fontSize: '1rem', fontWeight: 700, color: 'var(--text-main)' }}>
Завершённые заказы ({orders.length})
</h2>
<button
onClick={exportCsv}
className="btn-primary"
style={{ display: 'flex', alignItems: 'center', gap: '7px', padding: '8px 16px', fontSize: '0.85rem' }}
>
<Download size={14} />
Экспорт CSV
</button>
</div>
{loading ? (
<p style={{ textAlign: 'center', padding: '60px', color: 'var(--text-muted)' }}>Загрузка...</p>
) : orders.length === 0 ? (
<div className="glass-panel" style={{ textAlign: 'center', padding: '60px', color: 'var(--text-muted)' }}>
Нет завершённых заказов
</div>
) : (
<div className="glass-panel" style={{ overflow: 'hidden' }}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '700px' }}>
<thead>
<tr style={{ background: 'var(--bg-lighter)' }}>
{COLUMNS.map((col) => (
<th key={col.key} style={thStyle(col.key)} onClick={() => handleSort(col.key)}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px' }}>
{col.label}
<ArrowUpDown size={12} style={{ opacity: sortKey === col.key ? 1 : 0.35 }} />
</span>
</th>
))}
</tr>
</thead>
<tbody>
{sorted.map((order) => (
<tr key={order.id} style={{ transition: 'background 0.12s' }}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-lighter)'}
onMouseLeave={(e) => e.currentTarget.style.background = ''}
>
<td style={tdStyle}>
{order.closed_at ? new Date(order.closed_at).toLocaleDateString('ru-RU') : '—'}
</td>
<td style={{ ...tdStyle, fontWeight: 600 }}>{order.name}</td>
<td style={tdStyle}>{OBJECT_TYPE_LABELS[order.object_type] || order.object_type || '—'}</td>
<td style={tdStyle}>{order.area_sqm ? `${order.area_sqm} м²` : '—'}</td>
<td style={{ ...tdStyle, fontWeight: 700, color: 'var(--accent-orange)' }}>
{order.final_cost ? `${Number(order.final_cost).toLocaleString('ru-RU')}` : '—'}
</td>
<td style={{ ...tdStyle, color: 'var(--text-muted)' }}>{order.assigned_to || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
};
export default CompletedOrdersTable;