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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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: 'Пользователи' },
|
||||
|
||||
Reference in New Issue
Block a user