feat: archive page for closed orders older than 30 days

Adds /admin/archive route with a table of closed orders (status = 'закрыт') created more than 30 days ago. Each row opens OrderModal for viewing/editing; a Restore button resets status to 'новая'. Sidebar gets an Archive nav item between Аналитика and Завершённые.
This commit is contained in:
2026-05-12 11:37:59 +03:00
parent b2dd497d1f
commit bc73888355
4 changed files with 169 additions and 1 deletions
+2
View File
@@ -21,6 +21,7 @@ const UserManagementPage = React.lazy(() => import('./admin/users/UserManagement
const SettingsPage = React.lazy(() => import('./admin/settings/SettingsPage'));
const ClientsPage = React.lazy(() => import('./admin/clients/ClientsPage'));
const CalendarPage = React.lazy(() => import('./admin/calendar/CalendarPage'));
const ArchivePage = React.lazy(() => import('./admin/archive/ArchivePage'));
const OrderTrackingPage = React.lazy(() => import('./portal/OrderTrackingPage'));
function PublicSite() {
@@ -60,6 +61,7 @@ function App() {
<Route path="settings" element={<SettingsPage />} />
<Route path="clients" element={<ClientsPage />} />
<Route path="calendar" element={<CalendarPage />} />
<Route path="archive" element={<ArchivePage />} />
</Route>
</Route>
</Routes>
+128
View File
@@ -0,0 +1,128 @@
import { useState } from 'react';
import { Archive, RotateCcw } from 'lucide-react';
import { useArchive } from './useArchive';
import OrderModal from '../orders/OrderModal';
const thStyle = {
padding: '10px 14px', textAlign: 'left',
fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-muted)',
textTransform: 'uppercase', letterSpacing: '0.4px',
borderBottom: '1.5px solid var(--border-light)',
fontFamily: 'var(--font-heading)',
};
const tdStyle = {
padding: '12px 14px', fontSize: '0.88rem', color: 'var(--text-main)',
borderBottom: '1px solid var(--border-light)', fontFamily: 'var(--font-body)',
};
const formatDate = (s) => {
if (!s) return '—';
const d = new Date(s);
return isNaN(d) ? '—' : d.toLocaleDateString('ru-RU');
};
const ArchivePage = () => {
const { orders, loading, restoreOrder } = useArchive();
const [selectedOrder, setSelectedOrder] = useState(null);
const [restoringId, setRestoringId] = useState(null);
const handleRestore = async (e, order) => {
e.stopPropagation();
setRestoringId(order.id);
await restoreOrder(order.id);
setRestoringId(null);
};
return (
<div style={{ padding: '30px', maxWidth: '900px' }}>
<h1 style={{
fontFamily: 'var(--font-heading)', fontSize: '1.5rem', fontWeight: 800,
marginBottom: '8px', display: 'flex', alignItems: 'center', gap: '10px',
}}>
<Archive size={22} color="var(--accent-blue)" />
Архив
</h1>
<p style={{ color: 'var(--text-muted)', fontSize: '0.85rem', marginBottom: '24px' }}>
Закрытые заявки старше 30 дней. Нажмите на строку для просмотра, или восстановите в статус «новая».
</p>
<div className="glass-panel" style={{ padding: '0', overflow: 'hidden' }}>
{loading ? (
<div style={{ padding: '32px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.9rem' }}>
Загрузка...
</div>
) : orders.length === 0 ? (
<div style={{ padding: '32px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.9rem' }}>
Архив пуст нет закрытых заявок старше 30 дней
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '600px' }}>
<thead>
<tr>
<th style={thStyle}>Клиент</th>
<th style={thStyle}>Телефон</th>
<th style={thStyle}>Сумма</th>
<th style={thStyle}>Закрыта</th>
<th style={thStyle}>Менеджер</th>
<th style={{ ...thStyle, textAlign: 'right' }}></th>
</tr>
</thead>
<tbody>
{orders.map((o) => (
<tr
key={o.id}
onClick={() => setSelectedOrder(o)}
style={{ cursor: 'pointer', transition: 'background 0.1s' }}
onMouseEnter={(e) => e.currentTarget.style.background = '#F5F7FA'}
onMouseLeave={(e) => e.currentTarget.style.background = ''}
>
<td style={tdStyle}>{o.name}</td>
<td style={tdStyle}>{o.phone || '—'}</td>
<td style={{ ...tdStyle, fontWeight: 700, color: 'var(--accent-blue)' }}>
{o.final_cost ? `${Number(o.final_cost).toLocaleString('ru-RU')}` : '—'}
</td>
<td style={tdStyle}>{formatDate(o.closed_at || o.created_at)}</td>
<td style={{ ...tdStyle, color: 'var(--text-muted)' }}>{o.assigned_to || '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>
<button
onClick={(e) => handleRestore(e, o)}
disabled={restoringId === o.id}
title="Вернуть в работу"
style={{
background: 'none', border: '1.5px solid #E9ECEF',
borderRadius: '6px', padding: '5px 10px',
cursor: 'pointer', color: 'var(--text-muted)',
display: 'inline-flex', alignItems: 'center', gap: '5px',
fontSize: '0.75rem', fontFamily: 'var(--font-heading)',
opacity: restoringId === o.id ? 0.5 : 1,
}}
onMouseEnter={(e) => e.currentTarget.style.borderColor = '#4361EE'}
onMouseLeave={(e) => e.currentTarget.style.borderColor = '#E9ECEF'}
>
<RotateCcw size={12} />
Восстановить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{selectedOrder && (
<OrderModal
order={selectedOrder}
mode="edit"
onClose={() => setSelectedOrder(null)}
onSaved={() => setSelectedOrder(null)}
/>
)}
</div>
);
};
export default ArchivePage;
+37
View File
@@ -0,0 +1,37 @@
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '../../lib/supabase';
export function useArchive() {
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const fetch = useCallback(async () => {
let isMounted = true;
setLoading(true);
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 30);
const { data } = await supabase
.from('orders')
.select('id, name, phone, status, final_cost, created_at, closed_at, assigned_to, film_thickness, area_sqm, address')
.eq('status', 'закрыт')
.lt('created_at', cutoff.toISOString())
.order('closed_at', { ascending: false, nullsFirst: false });
if (isMounted) { setOrders(data ?? []); setLoading(false); }
return () => { isMounted = false; };
}, []);
useEffect(() => { fetch(); }, [fetch]);
const restoreOrder = async (orderId) => {
const { error } = await supabase
.from('orders')
.update({ status: 'новая', closed_at: null })
.eq('id', orderId);
if (!error) await fetch();
return { error };
};
return { orders, loading, restoreOrder, refetch: fetch };
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { NavLink, useNavigate } from 'react-router-dom';
import { LayoutDashboard, BarChart2, CheckSquare, Shield, LogOut, Users, Settings, BookUser, X, CalendarDays } from 'lucide-react';
import { LayoutDashboard, BarChart2, CheckSquare, Shield, LogOut, Users, Settings, BookUser, X, CalendarDays, Archive } from 'lucide-react';
import { useAuth } from '../auth/useAuth';
const NAV_ITEMS = [
{ to: '/admin/kanban', icon: LayoutDashboard, label: 'Канбан' },
{ to: '/admin/analytics', icon: BarChart2, label: 'Аналитика' },
{ to: '/admin/archive', icon: Archive, label: 'Архив' },
{ to: '/admin/completed', icon: CheckSquare, label: 'Завершённые' },
{ to: '/admin/calendar', icon: CalendarDays, label: 'Календарь' },
{ to: '/admin/users', icon: Users, label: 'Пользователи' },