feat: add Clients page with order history

Groups orders by phone into a client list with expandable rows showing per-order history, summary cards for total clients and closed revenue, and a Clients nav item in the sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 16:26:50 +03:00
parent 643fc62ddc
commit 8dae7f5d3b
4 changed files with 282 additions and 1 deletions
+2
View File
@@ -18,6 +18,7 @@ const AnalyticsDashboard = React.lazy(() => import('./admin/analytics/AnalyticsD
const CompletedOrdersTable = React.lazy(() => import('./admin/analytics/CompletedOrdersTable')); const CompletedOrdersTable = React.lazy(() => import('./admin/analytics/CompletedOrdersTable'));
const UserManagementPage = React.lazy(() => import('./admin/users/UserManagementPage')); const UserManagementPage = React.lazy(() => import('./admin/users/UserManagementPage'));
const SettingsPage = React.lazy(() => import('./admin/settings/SettingsPage')); const SettingsPage = React.lazy(() => import('./admin/settings/SettingsPage'));
const ClientsPage = React.lazy(() => import('./admin/clients/ClientsPage'));
function PublicSite() { function PublicSite() {
return ( return (
@@ -52,6 +53,7 @@ function App() {
<Route path="completed" element={<CompletedOrdersTable />} /> <Route path="completed" element={<CompletedOrdersTable />} />
<Route path="users" element={<UserManagementPage />} /> <Route path="users" element={<UserManagementPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="clients" element={<ClientsPage />} />
</Route> </Route>
</Route> </Route>
</Routes> </Routes>
+240
View File
@@ -0,0 +1,240 @@
import { useState } from 'react';
import { Users, ChevronDown, ChevronRight, BookUser } from 'lucide-react';
import { useClients } from './useClients';
const statusColor = (status) => {
switch (status) {
case 'закрыт': return '#22c55e';
case 'монтаж': return '#3b82f6';
case 'новая': return 'var(--text-muted)';
default: return 'var(--text-muted)';
}
};
const formatMoney = (value) => {
if (!value) return '—';
const num = Number(value);
if (!num) return '—';
return num.toLocaleString('ru-RU') + ' ₽';
};
const formatDate = (date) => {
if (!date) return '—';
return new Date(date).toLocaleDateString('ru-RU');
};
const thStyle = {
padding: '10px 14px',
textAlign: 'left',
fontSize: '0.72rem',
fontWeight: 700,
color: 'var(--text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.4px',
borderBottom: '1px solid var(--border-light)',
whiteSpace: 'nowrap',
};
const tdStyle = {
padding: '12px 14px',
fontSize: '0.88rem',
color: 'var(--text-main)',
borderBottom: '1px solid rgba(255,255,255,0.04)',
verticalAlign: 'middle',
};
const innerThStyle = {
padding: '8px 12px',
textAlign: 'left',
fontSize: '0.70rem',
fontWeight: 700,
color: 'var(--text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.4px',
borderBottom: '1px solid rgba(255,255,255,0.06)',
whiteSpace: 'nowrap',
};
const innerTdStyle = {
padding: '9px 12px',
fontSize: '0.83rem',
color: 'var(--text-main)',
borderBottom: '1px solid rgba(255,255,255,0.03)',
verticalAlign: 'middle',
};
const ClientsPage = () => {
const { clients, loading, error } = useClients();
const [expandedPhone, setExpandedPhone] = useState(null);
const toggleExpand = (phone) => {
setExpandedPhone((prev) => (prev === phone ? null : phone));
};
if (loading) {
return <div style={{ padding: '30px', color: 'var(--text-muted)' }}>Загрузка...</div>;
}
if (error) {
return <div style={{ padding: '30px', color: '#ef4444' }}>Ошибка: {error}</div>;
}
const totalClients = clients.length;
const totalRevenue = clients.reduce((sum, c) => sum + c.totalSpent, 0);
return (
<div style={{ padding: '30px', maxWidth: '1000px' }}>
{/* Page header */}
<h1 style={{
fontFamily: 'var(--font-heading)',
fontSize: '1.5rem',
fontWeight: 800,
marginBottom: '24px',
display: 'flex',
alignItems: 'center',
gap: '10px',
}}>
<Users size={22} color="var(--accent-blue)" />
Клиенты
</h1>
{/* Summary cards */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '28px', maxWidth: '480px' }}>
<div className="glass-panel" style={{ padding: '20px 24px' }}>
<p style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', marginBottom: '6px' }}>
Всего клиентов
</p>
<p style={{ fontSize: '1.8rem', fontWeight: 800, fontFamily: 'var(--font-heading)', color: 'var(--text-main)' }}>
{totalClients}
</p>
</div>
<div className="glass-panel" style={{ padding: '20px 24px' }}>
<p style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.4px', marginBottom: '6px' }}>
Выручка (закрытые)
</p>
<p style={{ fontSize: '1.8rem', fontWeight: 800, fontFamily: 'var(--font-heading)', color: 'var(--accent-blue)' }}>
{totalRevenue ? totalRevenue.toLocaleString('ru-RU') + ' ₽' : '—'}
</p>
</div>
</div>
{/* Clients table */}
<div className="glass-panel" style={{ padding: 0, overflow: 'hidden' }}>
{clients.length === 0 ? (
<p style={{ padding: '30px', color: 'var(--text-muted)', textAlign: 'center' }}>Нет клиентов</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ ...thStyle, width: '32px', padding: '10px 8px 10px 16px' }}></th>
<th style={thStyle}>Клиент</th>
<th style={thStyle}>Телефон</th>
<th style={{ ...thStyle, textAlign: 'right' }}>Заказов</th>
<th style={{ ...thStyle, textAlign: 'right' }}>Потрачено</th>
<th style={{ ...thStyle, textAlign: 'right' }}>Последний заказ</th>
</tr>
</thead>
<tbody>
{clients.map((client) => {
const isExpanded = expandedPhone === client.phone;
const lastOrder = client.orders[0];
return (
<>
<tr
key={client.phone}
onClick={() => toggleExpand(client.phone)}
style={{
cursor: 'pointer',
background: isExpanded ? 'rgba(14, 165, 233, 0.06)' : 'transparent',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
if (!isExpanded) e.currentTarget.style.background = 'rgba(255,255,255,0.03)';
}}
onMouseLeave={(e) => {
if (!isExpanded) e.currentTarget.style.background = 'transparent';
}}
>
<td style={{ ...tdStyle, padding: '12px 8px 12px 16px', color: 'var(--text-muted)' }}>
{isExpanded
? <ChevronDown size={15} />
: <ChevronRight size={15} />}
</td>
<td style={tdStyle}>
<span style={{ fontWeight: 600 }}>{client.name || '—'}</span>
</td>
<td style={{ ...tdStyle, fontFamily: 'var(--font-body)', color: 'var(--text-muted)' }}>
{client.phone !== 'unknown' ? client.phone : '—'}
</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>
{client.orders.length}
</td>
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: client.totalSpent ? 600 : 400 }}>
{client.totalSpent ? client.totalSpent.toLocaleString('ru-RU') + ' ₽' : '—'}
</td>
<td style={{ ...tdStyle, textAlign: 'right', color: 'var(--text-muted)', fontSize: '0.83rem' }}>
{lastOrder ? formatDate(lastOrder.created_at) : '—'}
</td>
</tr>
{/* Expanded order history */}
{isExpanded && (
<tr key={client.phone + '-expanded'}>
<td colSpan={6} style={{ padding: '0 0 0 48px', background: 'rgba(14, 165, 233, 0.04)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', borderLeft: '2px solid rgba(14, 165, 233, 0.25)' }}>
<thead>
<tr>
<th style={innerThStyle}>Дата</th>
<th style={innerThStyle}>Плёнка</th>
<th style={{ ...innerThStyle, textAlign: 'right' }}>Площадь (м²)</th>
<th style={{ ...innerThStyle, textAlign: 'right' }}>Сумма</th>
<th style={innerThStyle}>Статус</th>
</tr>
</thead>
<tbody>
{client.orders.map((order) => (
<tr key={order.id}>
<td style={{ ...innerTdStyle, color: 'var(--text-muted)', fontSize: '0.80rem' }}>
{formatDate(order.created_at)}
</td>
<td style={innerTdStyle}>
{order.film_thickness ? order.film_thickness + ' мкм' : '—'}
</td>
<td style={{ ...innerTdStyle, textAlign: 'right' }}>
{order.area_sqm ?? '—'}
</td>
<td style={{ ...innerTdStyle, textAlign: 'right', fontWeight: order.final_cost ? 600 : 400 }}>
{formatMoney(order.final_cost)}
</td>
<td style={innerTdStyle}>
<span style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 700,
color: statusColor(order.status),
background: statusColor(order.status) + '22',
}}>
{order.status || '—'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
);
})}
</tbody>
</table>
)}
</div>
</div>
);
};
export default ClientsPage;
+38
View File
@@ -0,0 +1,38 @@
import { useState, useEffect } from 'react';
import { supabase } from '../../lib/supabase';
export function useClients() {
const [clients, setClients] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
supabase
.from('orders')
.select('id, name, phone, film_thickness, area_sqm, final_cost, status, created_at, address')
.order('created_at', { ascending: false })
.then(({ data, error: err }) => {
if (!isMounted) return;
if (err) { setError(err.message); setLoading(false); return; }
const map = {};
(data || []).forEach((order) => {
const key = order.phone || 'unknown';
if (!map[key]) {
map[key] = { phone: key, name: order.name, orders: [], totalSpent: 0 };
}
map[key].orders.push(order);
if (order.status === 'закрыт' && order.final_cost) {
map[key].totalSpent += Number(order.final_cost);
}
});
// Sort clients by most recent order (orders are already newest-first)
const list = Object.values(map);
setClients(list);
setLoading(false);
});
return () => { isMounted = false; };
}, []);
return { clients, loading, error };
}
+2 -1
View File
@@ -1,5 +1,5 @@
import { NavLink, useNavigate } from 'react-router-dom'; import { NavLink, useNavigate } from 'react-router-dom';
import { LayoutDashboard, BarChart2, CheckSquare, Shield, LogOut, Users, Settings } from 'lucide-react'; import { LayoutDashboard, BarChart2, CheckSquare, Shield, LogOut, Users, Settings, BookUser } from 'lucide-react';
import { useAuth } from '../auth/useAuth'; import { useAuth } from '../auth/useAuth';
const NAV_ITEMS = [ const NAV_ITEMS = [
@@ -7,6 +7,7 @@ const NAV_ITEMS = [
{ to: '/admin/analytics', icon: BarChart2, label: 'Аналитика' }, { to: '/admin/analytics', icon: BarChart2, label: 'Аналитика' },
{ to: '/admin/completed', icon: CheckSquare, label: 'Завершённые' }, { to: '/admin/completed', icon: CheckSquare, label: 'Завершённые' },
{ to: '/admin/users', icon: Users, label: 'Пользователи' }, { to: '/admin/users', icon: Users, label: 'Пользователи' },
{ to: '/admin/clients', icon: BookUser, label: 'Клиенты' },
{ to: '/admin/settings', icon: Settings, label: 'Настройки' }, { to: '/admin/settings', icon: Settings, label: 'Настройки' },
]; ];