feat: add CompletedOrdersTable with sorting and CSV export
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user