From bc7388835573eeebc0f36fc1585346f18b4564ec Mon Sep 17 00:00:00 2001 From: houseassassin Date: Tue, 12 May 2026 11:37:59 +0300 Subject: [PATCH] feat: archive page for closed orders older than 30 days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Завершённые. --- src/App.jsx | 2 + src/admin/archive/ArchivePage.jsx | 128 ++++++++++++++++++++++++++++++ src/admin/archive/useArchive.js | 37 +++++++++ src/admin/layout/Sidebar.jsx | 3 +- 4 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 src/admin/archive/ArchivePage.jsx create mode 100644 src/admin/archive/useArchive.js diff --git a/src/App.jsx b/src/App.jsx index 3923a47..9ed6ae9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { } /> } /> } /> + } /> diff --git a/src/admin/archive/ArchivePage.jsx b/src/admin/archive/ArchivePage.jsx new file mode 100644 index 0000000..b25ba0b --- /dev/null +++ b/src/admin/archive/ArchivePage.jsx @@ -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 ( +
+

+ + Архив +

+

+ Закрытые заявки старше 30 дней. Нажмите на строку для просмотра, или восстановите в статус «новая». +

+ +
+ {loading ? ( +
+ Загрузка... +
+ ) : orders.length === 0 ? ( +
+ Архив пуст — нет закрытых заявок старше 30 дней +
+ ) : ( +
+ + + + + + + + + + + + + {orders.map((o) => ( + setSelectedOrder(o)} + style={{ cursor: 'pointer', transition: 'background 0.1s' }} + onMouseEnter={(e) => e.currentTarget.style.background = '#F5F7FA'} + onMouseLeave={(e) => e.currentTarget.style.background = ''} + > + + + + + + + + ))} + +
КлиентТелефонСуммаЗакрытаМенеджер
{o.name}{o.phone || '—'} + {o.final_cost ? `${Number(o.final_cost).toLocaleString('ru-RU')} ₽` : '—'} + {formatDate(o.closed_at || o.created_at)}{o.assigned_to || '—'} + +
+
+ )} +
+ + {selectedOrder && ( + setSelectedOrder(null)} + onSaved={() => setSelectedOrder(null)} + /> + )} +
+ ); +}; + +export default ArchivePage; diff --git a/src/admin/archive/useArchive.js b/src/admin/archive/useArchive.js new file mode 100644 index 0000000..543d4d5 --- /dev/null +++ b/src/admin/archive/useArchive.js @@ -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 }; +} diff --git a/src/admin/layout/Sidebar.jsx b/src/admin/layout/Sidebar.jsx index 3566ba8..b38d478 100644 --- a/src/admin/layout/Sidebar.jsx +++ b/src/admin/layout/Sidebar.jsx @@ -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: 'Пользователи' },