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:
@@ -18,6 +18,7 @@ const AnalyticsDashboard = React.lazy(() => import('./admin/analytics/AnalyticsD
|
||||
const CompletedOrdersTable = React.lazy(() => import('./admin/analytics/CompletedOrdersTable'));
|
||||
const UserManagementPage = React.lazy(() => import('./admin/users/UserManagementPage'));
|
||||
const SettingsPage = React.lazy(() => import('./admin/settings/SettingsPage'));
|
||||
const ClientsPage = React.lazy(() => import('./admin/clients/ClientsPage'));
|
||||
|
||||
function PublicSite() {
|
||||
return (
|
||||
@@ -52,6 +53,7 @@ function App() {
|
||||
<Route path="completed" element={<CompletedOrdersTable />} />
|
||||
<Route path="users" element={<UserManagementPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="clients" element={<ClientsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@@ -7,6 +7,7 @@ const NAV_ITEMS = [
|
||||
{ to: '/admin/analytics', icon: BarChart2, label: 'Аналитика' },
|
||||
{ to: '/admin/completed', icon: CheckSquare, label: 'Завершённые' },
|
||||
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
||||
{ to: '/admin/clients', icon: BookUser, label: 'Клиенты' },
|
||||
{ to: '/admin/settings', icon: Settings, label: 'Настройки' },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user