1829
package-lock.json
generated
1829
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -10,12 +10,15 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astryxdesign/cli": "^0.1.2",
|
"@emotion/react": "^11.11.4",
|
||||||
"@astryxdesign/core": "^0.1.2",
|
"@emotion/styled": "^11.11.5",
|
||||||
"@astryxdesign/theme-neutral": "^0.1.2",
|
"@mui/icons-material": "^5.15.20",
|
||||||
|
"@mui/lab": "^5.0.0-alpha.170",
|
||||||
|
"@mui/material": "^5.15.20",
|
||||||
|
"@mui/x-date-pickers": "^6.20.2",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"react": "^19.2.7",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.23.1"
|
"react-router-dom": "^6.23.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB |
10
src/App.jsx
10
src/App.jsx
@@ -1,5 +1,6 @@
|
|||||||
import { Suspense, lazy } from 'react';
|
import { Suspense, lazy } from 'react';
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { Box, CircularProgress } from '@mui/material';
|
||||||
|
|
||||||
import MainLayout from '@/layout/MainLayout';
|
import MainLayout from '@/layout/MainLayout';
|
||||||
import MinimalLayout from '@/layout/MinimalLayout';
|
import MinimalLayout from '@/layout/MinimalLayout';
|
||||||
@@ -11,10 +12,9 @@ const load = (factory) => {
|
|||||||
return (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
|
||||||
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #C01227', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
<CircularProgress color="primary" />
|
||||||
<style>{`@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`}</style>
|
</Box>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<C />
|
<C />
|
||||||
@@ -29,7 +29,7 @@ export default function App() {
|
|||||||
<Route element={<AuthGuard><MainLayout /></AuthGuard>}>
|
<Route element={<AuthGuard><MainLayout /></AuthGuard>}>
|
||||||
<Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} />
|
<Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} />
|
||||||
|
|
||||||
<Route path="/clients" element={load(() => import('@/pages/tenants/Tenants'))} />
|
<Route path="/tenants" element={load(() => import('@/pages/tenants/Tenants'))} />
|
||||||
|
|
||||||
<Route path="/survey" element={load(() => import('@/pages/survey/Survey.jsx'))} />
|
<Route path="/survey" element={load(() => import('@/pages/survey/Survey.jsx'))} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,808 +0,0 @@
|
|||||||
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import {
|
|
||||||
VStack,
|
|
||||||
HStack,
|
|
||||||
Layout,
|
|
||||||
LayoutContent,
|
|
||||||
} from '@astryxdesign/core/Layout';
|
|
||||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
|
||||||
import {Card} from '@astryxdesign/core/Card';
|
|
||||||
import {Button} from '@astryxdesign/core/Button';
|
|
||||||
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
|
||||||
import {
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
} from 'recharts';
|
|
||||||
import {Grid} from '@astryxdesign/core/Grid';
|
|
||||||
import {Table, proportional, pixel} from '@astryxdesign/core/Table';
|
|
||||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
|
||||||
import {Divider} from '@astryxdesign/core/Divider';
|
|
||||||
import {Link} from '@astryxdesign/core/Link';
|
|
||||||
import {Icon} from '@astryxdesign/core/Icon';
|
|
||||||
|
|
||||||
// ============= ICONS =============
|
|
||||||
|
|
||||||
import {
|
|
||||||
ArrowPathIcon,
|
|
||||||
ArrowUpIcon,
|
|
||||||
ArrowDownIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
import {StopIcon} from '@heroicons/react/24/solid';
|
|
||||||
|
|
||||||
// ============= DATA =============
|
|
||||||
|
|
||||||
// Active users chart data (96 points over 24h at 15-min intervals: Apr 1 14:00 → Apr 2 14:00)
|
|
||||||
// Each point has an index (0–95) for even spacing, plus a label for display
|
|
||||||
const activeUsersData = [
|
|
||||||
// Apr 1 14:00 — mid-afternoon, strong work hours
|
|
||||||
{hour: 0, label: 'Apr 1 14:00', allUsers: 116, desktop: 79, mobile: 37},
|
|
||||||
{hour: 1, label: 'Apr 1 14:15', allUsers: 118, desktop: 80, mobile: 38},
|
|
||||||
{hour: 2, label: 'Apr 1 14:30', allUsers: 117, desktop: 79, mobile: 38},
|
|
||||||
{hour: 3, label: 'Apr 1 14:45', allUsers: 119, desktop: 81, mobile: 38},
|
|
||||||
// Apr 1 15:00 — afternoon lull
|
|
||||||
{hour: 4, label: 'Apr 1 15:00', allUsers: 116, desktop: 79, mobile: 37},
|
|
||||||
{hour: 5, label: 'Apr 1 15:15', allUsers: 113, desktop: 76, mobile: 37},
|
|
||||||
{hour: 6, label: 'Apr 1 15:30', allUsers: 109, desktop: 72, mobile: 37},
|
|
||||||
{hour: 7, label: 'Apr 1 15:45', allUsers: 111, desktop: 74, mobile: 37},
|
|
||||||
// Apr 1 16:00 — late afternoon, some leaving early
|
|
||||||
{hour: 8, label: 'Apr 1 16:00', allUsers: 110, desktop: 72, mobile: 38},
|
|
||||||
{hour: 9, label: 'Apr 1 16:15', allUsers: 107, desktop: 69, mobile: 38},
|
|
||||||
{hour: 10, label: 'Apr 1 16:30', allUsers: 104, desktop: 66, mobile: 38},
|
|
||||||
{hour: 11, label: 'Apr 1 16:45', allUsers: 101, desktop: 62, mobile: 39},
|
|
||||||
// Apr 1 17:00 — 5PM exodus, desktop drops fast, mobile bumps
|
|
||||||
{hour: 12, label: 'Apr 1 17:00', allUsers: 100, desktop: 57, mobile: 43},
|
|
||||||
{hour: 13, label: 'Apr 1 17:15', allUsers: 99, desktop: 54, mobile: 45},
|
|
||||||
{hour: 14, label: 'Apr 1 17:30', allUsers: 97, desktop: 50, mobile: 47},
|
|
||||||
{hour: 15, label: 'Apr 1 17:45', allUsers: 95, desktop: 46, mobile: 49},
|
|
||||||
// Apr 1 18:00 — commute, mobile overtakes desktop
|
|
||||||
{hour: 16, label: 'Apr 1 18:00', allUsers: 93, desktop: 41, mobile: 52},
|
|
||||||
{hour: 17, label: 'Apr 1 18:15', allUsers: 91, desktop: 39, mobile: 52},
|
|
||||||
{hour: 18, label: 'Apr 1 18:30', allUsers: 89, desktop: 37, mobile: 52},
|
|
||||||
{hour: 19, label: 'Apr 1 18:45', allUsers: 87, desktop: 36, mobile: 51},
|
|
||||||
// Apr 1 19:00 — dinner, decline slowing
|
|
||||||
{hour: 20, label: 'Apr 1 19:00', allUsers: 85, desktop: 34, mobile: 51},
|
|
||||||
{hour: 21, label: 'Apr 1 19:15', allUsers: 83, desktop: 32, mobile: 51},
|
|
||||||
{hour: 22, label: 'Apr 1 19:30', allUsers: 80, desktop: 30, mobile: 50},
|
|
||||||
{hour: 23, label: 'Apr 1 19:45', allUsers: 77, desktop: 28, mobile: 49},
|
|
||||||
// Apr 1 20:00 — couch browsing, mobile plateau
|
|
||||||
{hour: 24, label: 'Apr 1 20:00', allUsers: 75, desktop: 26, mobile: 49},
|
|
||||||
{hour: 25, label: 'Apr 1 20:15', allUsers: 76, desktop: 26, mobile: 50},
|
|
||||||
{hour: 26, label: 'Apr 1 20:30', allUsers: 76, desktop: 27, mobile: 49},
|
|
||||||
{hour: 27, label: 'Apr 1 20:45', allUsers: 75, desktop: 27, mobile: 48},
|
|
||||||
// Apr 1 21:00 — winding down, mobile dropping off
|
|
||||||
{hour: 28, label: 'Apr 1 21:00', allUsers: 73, desktop: 27, mobile: 46},
|
|
||||||
{hour: 29, label: 'Apr 1 21:15', allUsers: 71, desktop: 27, mobile: 44},
|
|
||||||
{hour: 30, label: 'Apr 1 21:30', allUsers: 69, desktop: 27, mobile: 42},
|
|
||||||
{hour: 31, label: 'Apr 1 21:45', allUsers: 67, desktop: 28, mobile: 39},
|
|
||||||
// Apr 1 22:00 — bedtime wave, mobile drops, desktop holds
|
|
||||||
{hour: 32, label: 'Apr 1 22:00', allUsers: 65, desktop: 29, mobile: 36},
|
|
||||||
{hour: 33, label: 'Apr 1 22:15', allUsers: 63, desktop: 29, mobile: 34},
|
|
||||||
{hour: 34, label: 'Apr 1 22:30', allUsers: 61, desktop: 30, mobile: 31},
|
|
||||||
{hour: 35, label: 'Apr 1 22:45', allUsers: 60, desktop: 31, mobile: 29},
|
|
||||||
// Apr 1 23:00 — desktop overtakes as local users sleep, other TZs active
|
|
||||||
{hour: 36, label: 'Apr 1 23:00', allUsers: 59, desktop: 33, mobile: 26},
|
|
||||||
{hour: 37, label: 'Apr 1 23:15', allUsers: 58, desktop: 34, mobile: 24},
|
|
||||||
{hour: 38, label: 'Apr 1 23:30', allUsers: 57, desktop: 35, mobile: 22},
|
|
||||||
{hour: 39, label: 'Apr 1 23:45', allUsers: 56, desktop: 36, mobile: 20},
|
|
||||||
// Apr 2 00:00 — EMEA morning starts, desktop dominant
|
|
||||||
{hour: 40, label: 'Apr 2 00:00', allUsers: 56, desktop: 38, mobile: 18},
|
|
||||||
{hour: 41, label: 'Apr 2 00:15', allUsers: 56, desktop: 39, mobile: 17},
|
|
||||||
{hour: 42, label: 'Apr 2 00:30', allUsers: 57, desktop: 40, mobile: 17},
|
|
||||||
{hour: 43, label: 'Apr 2 00:45', allUsers: 56, desktop: 40, mobile: 16},
|
|
||||||
// Apr 2 01:00 — EMEA working, plateau
|
|
||||||
{hour: 44, label: 'Apr 2 01:00', allUsers: 56, desktop: 41, mobile: 15},
|
|
||||||
{hour: 45, label: 'Apr 2 01:15', allUsers: 55, desktop: 41, mobile: 14},
|
|
||||||
{hour: 46, label: 'Apr 2 01:30', allUsers: 55, desktop: 41, mobile: 14},
|
|
||||||
{hour: 47, label: 'Apr 2 01:45', allUsers: 54, desktop: 40, mobile: 14},
|
|
||||||
// Apr 2 02:00 — EMEA mid-morning, holding steady
|
|
||||||
{hour: 48, label: 'Apr 2 02:00', allUsers: 54, desktop: 40, mobile: 14},
|
|
||||||
{hour: 49, label: 'Apr 2 02:15', allUsers: 53, desktop: 39, mobile: 14},
|
|
||||||
{hour: 50, label: 'Apr 2 02:30', allUsers: 53, desktop: 39, mobile: 14},
|
|
||||||
{hour: 51, label: 'Apr 2 02:45', allUsers: 52, desktop: 38, mobile: 14},
|
|
||||||
// Apr 2 03:00 — EMEA lunch gap, slight dip
|
|
||||||
{hour: 52, label: 'Apr 2 03:00', allUsers: 51, desktop: 37, mobile: 14},
|
|
||||||
{hour: 53, label: 'Apr 2 03:15', allUsers: 50, desktop: 36, mobile: 14},
|
|
||||||
{hour: 54, label: 'Apr 2 03:30', allUsers: 49, desktop: 35, mobile: 14},
|
|
||||||
{hour: 55, label: 'Apr 2 03:45', allUsers: 49, desktop: 35, mobile: 14},
|
|
||||||
// Apr 2 04:00 — EMEA afternoon, floor
|
|
||||||
{hour: 56, label: 'Apr 2 04:00', allUsers: 48, desktop: 34, mobile: 14},
|
|
||||||
{hour: 57, label: 'Apr 2 04:15', allUsers: 48, desktop: 33, mobile: 15},
|
|
||||||
{hour: 58, label: 'Apr 2 04:30', allUsers: 49, desktop: 33, mobile: 16},
|
|
||||||
{hour: 59, label: 'Apr 2 04:45', allUsers: 49, desktop: 32, mobile: 17},
|
|
||||||
// Apr 2 05:00 — early risers checking phones, mobile climbing
|
|
||||||
{hour: 60, label: 'Apr 2 05:00', allUsers: 50, desktop: 31, mobile: 19},
|
|
||||||
{hour: 61, label: 'Apr 2 05:15', allUsers: 51, desktop: 30, mobile: 21},
|
|
||||||
{hour: 62, label: 'Apr 2 05:30', allUsers: 52, desktop: 29, mobile: 23},
|
|
||||||
{hour: 63, label: 'Apr 2 05:45', allUsers: 54, desktop: 28, mobile: 26},
|
|
||||||
// Apr 2 06:00 — alarms going off, mobile surging
|
|
||||||
{hour: 64, label: 'Apr 2 06:00', allUsers: 56, desktop: 27, mobile: 29},
|
|
||||||
{hour: 65, label: 'Apr 2 06:15', allUsers: 58, desktop: 26, mobile: 32},
|
|
||||||
{hour: 66, label: 'Apr 2 06:30', allUsers: 61, desktop: 26, mobile: 35},
|
|
||||||
{hour: 67, label: 'Apr 2 06:45', allUsers: 64, desktop: 27, mobile: 37},
|
|
||||||
// Apr 2 07:00 — commute, mobile peaks, desktop starting
|
|
||||||
{hour: 68, label: 'Apr 2 07:00', allUsers: 67, desktop: 28, mobile: 39},
|
|
||||||
{hour: 69, label: 'Apr 2 07:15', allUsers: 70, desktop: 30, mobile: 40},
|
|
||||||
{hour: 70, label: 'Apr 2 07:30', allUsers: 73, desktop: 33, mobile: 40},
|
|
||||||
{hour: 71, label: 'Apr 2 07:45', allUsers: 76, desktop: 37, mobile: 39},
|
|
||||||
// Apr 2 08:00 — arriving at desks, desktop ramping
|
|
||||||
{hour: 72, label: 'Apr 2 08:00', allUsers: 80, desktop: 43, mobile: 37},
|
|
||||||
{hour: 73, label: 'Apr 2 08:15', allUsers: 85, desktop: 49, mobile: 36},
|
|
||||||
{hour: 74, label: 'Apr 2 08:30', allUsers: 90, desktop: 55, mobile: 35},
|
|
||||||
{hour: 75, label: 'Apr 2 08:45', allUsers: 95, desktop: 61, mobile: 34},
|
|
||||||
// Apr 2 09:00 — work day, desktop dominant
|
|
||||||
{hour: 76, label: 'Apr 2 09:00', allUsers: 99, desktop: 66, mobile: 33},
|
|
||||||
{hour: 77, label: 'Apr 2 09:15', allUsers: 102, desktop: 69, mobile: 33},
|
|
||||||
{hour: 78, label: 'Apr 2 09:30', allUsers: 104, desktop: 72, mobile: 32},
|
|
||||||
{hour: 79, label: 'Apr 2 09:45', allUsers: 104, desktop: 72, mobile: 32},
|
|
||||||
// Apr 2 10:00 — coffee break stall, then climbing
|
|
||||||
{hour: 80, label: 'Apr 2 10:00', allUsers: 106, desktop: 74, mobile: 32},
|
|
||||||
{hour: 81, label: 'Apr 2 10:15', allUsers: 109, desktop: 76, mobile: 33},
|
|
||||||
{hour: 82, label: 'Apr 2 10:30', allUsers: 112, desktop: 78, mobile: 34},
|
|
||||||
{hour: 83, label: 'Apr 2 10:45', allUsers: 114, desktop: 80, mobile: 34},
|
|
||||||
// Apr 2 11:00 — approaching peak
|
|
||||||
{hour: 84, label: 'Apr 2 11:00', allUsers: 116, desktop: 81, mobile: 35},
|
|
||||||
{hour: 85, label: 'Apr 2 11:15', allUsers: 117, desktop: 81, mobile: 36},
|
|
||||||
{hour: 86, label: 'Apr 2 11:30', allUsers: 119, desktop: 83, mobile: 36},
|
|
||||||
{hour: 87, label: 'Apr 2 11:45', allUsers: 119, desktop: 82, mobile: 37},
|
|
||||||
// Apr 2 12:00 — lunch dip, mobile ticks up
|
|
||||||
{hour: 88, label: 'Apr 2 12:00', allUsers: 120, desktop: 83, mobile: 37},
|
|
||||||
{hour: 89, label: 'Apr 2 12:15', allUsers: 115, desktop: 78, mobile: 37},
|
|
||||||
{hour: 90, label: 'Apr 2 12:30', allUsers: 111, desktop: 74, mobile: 37},
|
|
||||||
{hour: 91, label: 'Apr 2 12:45', allUsers: 110, desktop: 73, mobile: 37},
|
|
||||||
// Apr 2 13:00 — returning from lunch
|
|
||||||
{hour: 92, label: 'Apr 2 13:00', allUsers: 113, desktop: 76, mobile: 37},
|
|
||||||
{hour: 93, label: 'Apr 2 13:15', allUsers: 116, desktop: 79, mobile: 37},
|
|
||||||
{hour: 94, label: 'Apr 2 13:30', allUsers: 118, desktop: 81, mobile: 37},
|
|
||||||
{hour: 95, label: 'Apr 2 14:00', allUsers: 120, desktop: 83, mobile: 37},
|
|
||||||
];
|
|
||||||
|
|
||||||
// X-axis tick indices and their display labels
|
|
||||||
const xAxisTicks = [0, 32, 64, 95];
|
|
||||||
const xAxisLabels: Record<number, string> = {
|
|
||||||
0: 'Apr 1 14:00',
|
|
||||||
32: 'Apr 1 22:00',
|
|
||||||
64: 'Apr 2 06:00',
|
|
||||||
95: 'Apr 2 14:00',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Metric cards
|
|
||||||
const metrics = [
|
|
||||||
{
|
|
||||||
label: 'Monthly Visitors',
|
|
||||||
value: '27.3 k',
|
|
||||||
change: '+18.2%',
|
|
||||||
positive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Monthly Page Views',
|
|
||||||
value: '48.2 k',
|
|
||||||
change: '+12.5%',
|
|
||||||
positive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Avg. Session',
|
|
||||||
value: '4.5 min',
|
|
||||||
change: '-14.3%',
|
|
||||||
positive: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Bounce Rate',
|
|
||||||
value: '42.3%',
|
|
||||||
change: '-8.7%',
|
|
||||||
positive: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Sparkline data for each metric card (30 days, weekends at indices 5-6, 12-13, 19-20, 26-27)
|
|
||||||
const sparklines = [
|
|
||||||
// Monthly Visitors: +18.2% — declining first 2 weeks, hits bottom around day 14, then sharp recovery
|
|
||||||
// prettier-ignore
|
|
||||||
[48, 46, 44, 42, 40, 18, 16, 38, 36, 34, 32, 30, 12, 10, 28, 26, 28, 32, 36, 14, 12, 40, 44, 48, 52, 56, 28, 24, 58, 62],
|
|
||||||
// Monthly Page Views: +12.5% — flat/choppy first 3 weeks, then kicks up sharply in final week
|
|
||||||
// prettier-ignore
|
|
||||||
[36, 38, 35, 37, 36, 14, 12, 38, 36, 34, 37, 35, 12, 10, 36, 34, 36, 35, 38, 14, 12, 40, 44, 50, 54, 56, 26, 22, 58, 60],
|
|
||||||
// Avg. Session: -14.3% — strong start, holds through week 2, then clear drop-off week 3-4
|
|
||||||
// prettier-ignore
|
|
||||||
[58, 56, 60, 58, 62, 30, 26, 60, 58, 62, 60, 58, 28, 24, 56, 54, 50, 46, 42, 18, 14, 38, 36, 34, 32, 30, 10, 8, 28, 26],
|
|
||||||
// Bounce Rate: -8.7% — high and volatile first half, starts dropping around day 16, steady decline
|
|
||||||
// prettier-ignore
|
|
||||||
[52, 56, 50, 54, 58, 62, 60, 54, 52, 56, 50, 54, 60, 58, 50, 48, 46, 44, 40, 46, 44, 38, 36, 34, 36, 32, 38, 36, 30, 28],
|
|
||||||
];
|
|
||||||
|
|
||||||
// Demographics
|
|
||||||
const regionData = [
|
|
||||||
{
|
|
||||||
label: 'NORAM',
|
|
||||||
value: 38,
|
|
||||||
color: 'var(--color-data-categorical-blue, #0171E3)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'EMEA',
|
|
||||||
value: 28,
|
|
||||||
color: 'var(--color-data-categorical-orange, #EB6E00)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'APAC',
|
|
||||||
value: 22,
|
|
||||||
color: 'var(--color-data-categorical-green, #0B991F)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'LATAM',
|
|
||||||
value: 8,
|
|
||||||
color: 'var(--color-data-categorical-purple, #6B1EFD)',
|
|
||||||
},
|
|
||||||
{label: 'Other', value: 4, color: 'var(--color-data-neutral, #8494A3)'},
|
|
||||||
];
|
|
||||||
|
|
||||||
const roleData = [
|
|
||||||
{
|
|
||||||
label: 'Engineer',
|
|
||||||
value: 45,
|
|
||||||
color: 'var(--color-data-categorical-blue, #0171E3)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Manager',
|
|
||||||
value: 20,
|
|
||||||
color: 'var(--color-data-categorical-orange, #EB6E00)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Designer',
|
|
||||||
value: 15,
|
|
||||||
color: 'var(--color-data-categorical-green, #0B991F)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Data Scientist',
|
|
||||||
value: 12,
|
|
||||||
color: 'var(--color-data-categorical-purple, #6B1EFD)',
|
|
||||||
},
|
|
||||||
{label: 'Other', value: 8, color: 'var(--color-data-neutral, #8494A3)'},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Engagement — Top pages
|
|
||||||
interface PageRow extends Record<string, unknown> {
|
|
||||||
id: string;
|
|
||||||
page: string;
|
|
||||||
views: number;
|
|
||||||
newUsers: string;
|
|
||||||
avgTime: string;
|
|
||||||
exits: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const topPagesData: PageRow[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
page: '/home',
|
|
||||||
views: 8420,
|
|
||||||
newUsers: '62.3%',
|
|
||||||
avgTime: '3:42',
|
|
||||||
exits: '18.5%',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
page: '/products',
|
|
||||||
views: 6150,
|
|
||||||
newUsers: '45.1%',
|
|
||||||
avgTime: '4:15',
|
|
||||||
exits: '22.8%',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
page: '/pricing',
|
|
||||||
views: 4830,
|
|
||||||
newUsers: '38.7%',
|
|
||||||
avgTime: '2:58',
|
|
||||||
exits: '35.2%',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
page: '/blog',
|
|
||||||
views: 3920,
|
|
||||||
newUsers: '71.4%',
|
|
||||||
avgTime: '5:30',
|
|
||||||
exits: '12.1%',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5',
|
|
||||||
page: '/docs',
|
|
||||||
views: 3410,
|
|
||||||
newUsers: '29.8%',
|
|
||||||
avgTime: '6:12',
|
|
||||||
exits: '8.4%',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '6',
|
|
||||||
page: '/about',
|
|
||||||
views: 2980,
|
|
||||||
newUsers: '55.6%',
|
|
||||||
avgTime: '2:15',
|
|
||||||
exits: '28.3%',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '7',
|
|
||||||
page: '/contact',
|
|
||||||
views: 2540,
|
|
||||||
newUsers: '48.2%',
|
|
||||||
avgTime: '1:48',
|
|
||||||
exits: '41.7%',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '8',
|
|
||||||
page: '/changelog',
|
|
||||||
views: 2210,
|
|
||||||
newUsers: '22.1%',
|
|
||||||
avgTime: '4:55',
|
|
||||||
exits: '15.6%',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '9',
|
|
||||||
page: '/support',
|
|
||||||
views: 1870,
|
|
||||||
newUsers: '59.3%',
|
|
||||||
avgTime: '3:22',
|
|
||||||
exits: '30.9%',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '10',
|
|
||||||
page: '/careers',
|
|
||||||
views: 1520,
|
|
||||||
newUsers: '83.1%',
|
|
||||||
avgTime: '2:34',
|
|
||||||
exits: '45.2%',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const topPagesMaxViews = Math.max(...topPagesData.map(d => d.views));
|
|
||||||
|
|
||||||
const topPagesColumns: TableColumn<PageRow>[] = [
|
|
||||||
{key: 'page', header: 'Page', width: pixel(160)},
|
|
||||||
{
|
|
||||||
key: 'views',
|
|
||||||
header: 'Views',
|
|
||||||
width: proportional(1),
|
|
||||||
renderCell: (item: PageRow) => (
|
|
||||||
<VStack gap={1}>
|
|
||||||
<ProgressBar
|
|
||||||
value={item.views}
|
|
||||||
max={topPagesMaxViews}
|
|
||||||
label={`${item.page} views`}
|
|
||||||
isLabelHidden
|
|
||||||
/>
|
|
||||||
<Text type="supporting">{item.views.toLocaleString()} views</Text>
|
|
||||||
</VStack>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{key: 'newUsers', header: 'New Users', width: pixel(120)},
|
|
||||||
{key: 'avgTime', header: 'Avg. Time', width: pixel(120)},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Engagement — Top events
|
|
||||||
interface EventRow extends Record<string, unknown> {
|
|
||||||
id: string;
|
|
||||||
event: string;
|
|
||||||
count: number;
|
|
||||||
users: number;
|
|
||||||
newUsers: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const topEventsData: EventRow[] = [
|
|
||||||
{id: '1', event: 'page_view', count: 18420, users: 12300, newUsers: 4920},
|
|
||||||
{id: '2', event: 'session_start', count: 14850, users: 9870, newUsers: 3950},
|
|
||||||
{id: '3', event: 'first_visit', count: 8230, users: 8230, newUsers: 8230},
|
|
||||||
{id: '4', event: 'user_engagement', count: 6120, users: 4510, newUsers: 1580},
|
|
||||||
{id: '5', event: 'click', count: 3540, users: 2680, newUsers: 940},
|
|
||||||
{id: '6', event: 'scroll', count: 2910, users: 2140, newUsers: 750},
|
|
||||||
{id: '7', event: 'form_submit', count: 1870, users: 1350, newUsers: 540},
|
|
||||||
{id: '8', event: 'video_play', count: 1240, users: 980, newUsers: 390},
|
|
||||||
{id: '9', event: 'search', count: 960, users: 720, newUsers: 290},
|
|
||||||
{id: '10', event: 'share', count: 580, users: 410, newUsers: 160},
|
|
||||||
];
|
|
||||||
|
|
||||||
const topEventsMaxCount = Math.max(...topEventsData.map(d => d.count));
|
|
||||||
|
|
||||||
const topEventsColumns: TableColumn<EventRow>[] = [
|
|
||||||
{key: 'event', header: 'Event', width: pixel(160)},
|
|
||||||
{
|
|
||||||
key: 'count',
|
|
||||||
header: 'Count',
|
|
||||||
width: proportional(1),
|
|
||||||
renderCell: (item: EventRow) => (
|
|
||||||
<VStack gap={1}>
|
|
||||||
<ProgressBar
|
|
||||||
value={item.count}
|
|
||||||
max={topEventsMaxCount}
|
|
||||||
label={`${item.count}`}
|
|
||||||
isLabelHidden
|
|
||||||
/>
|
|
||||||
<Text type="supporting">{item.count.toLocaleString()}</Text>
|
|
||||||
</VStack>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'users',
|
|
||||||
header: 'Users',
|
|
||||||
width: pixel(120),
|
|
||||||
renderCell: (item: EventRow) => item.users.toLocaleString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'newUsers',
|
|
||||||
header: 'New Users',
|
|
||||||
width: pixel(120),
|
|
||||||
renderCell: (item: EventRow) => item.newUsers.toLocaleString(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ============= CHART COMPONENTS =============
|
|
||||||
|
|
||||||
// Chart line colors via Astryx design tokens (CSS custom properties)
|
|
||||||
const chartColors = {
|
|
||||||
allUsers: 'var(--color-data-categorical-blue, #0171E3)',
|
|
||||||
desktop: 'var(--color-data-categorical-orange, #EB6E00)',
|
|
||||||
mobile: 'var(--color-data-categorical-purple, #6B1EFD)',
|
|
||||||
};
|
|
||||||
|
|
||||||
function ChartLegendItem({color, label}: {color: string; label: string}) {
|
|
||||||
return (
|
|
||||||
<HStack gap={2} vAlign="center">
|
|
||||||
<Icon icon={StopIcon} size="xsm" style={{color}} />
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChartTooltip({
|
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
active?: boolean;
|
|
||||||
payload?: Array<{name: string; value: number; color: string}>;
|
|
||||||
label?: number;
|
|
||||||
}) {
|
|
||||||
if (!active || !payload?.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const point = activeUsersData.find(d => d.hour === label);
|
|
||||||
return (
|
|
||||||
<Card padding={3}>
|
|
||||||
<VStack gap={1}>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
{point?.label ?? ''}
|
|
||||||
</Text>
|
|
||||||
{payload.map(entry => (
|
|
||||||
<HStack key={entry.name} gap={2} vAlign="center">
|
|
||||||
<Icon icon={StopIcon} size="xsm" style={{color: entry.color}} />
|
|
||||||
<Text type="supporting">
|
|
||||||
{entry.name}: {entry.value}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActiveUsersChart() {
|
|
||||||
return (
|
|
||||||
<VStack gap={3}>
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<LineChart
|
|
||||||
data={activeUsersData}
|
|
||||||
margin={{top: 5, right: 10, left: 0, bottom: 5}}>
|
|
||||||
<CartesianGrid
|
|
||||||
horizontal
|
|
||||||
vertical={false}
|
|
||||||
stroke="var(--color-border, rgba(5, 54, 89, 0.1))"
|
|
||||||
/>
|
|
||||||
<XAxis
|
|
||||||
dataKey="hour"
|
|
||||||
type="number"
|
|
||||||
domain={[0, 23]}
|
|
||||||
ticks={xAxisTicks}
|
|
||||||
tickFormatter={(v: number) => xAxisLabels[v] ?? ''}
|
|
||||||
tick={{
|
|
||||||
fontSize: 'var(--font-size-sm, 12px)',
|
|
||||||
fill: 'var(--color-text-secondary, #4E606F)',
|
|
||||||
}}
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
domain={[0, 120]}
|
|
||||||
ticks={[0, 20, 40, 60, 80, 100, 120]}
|
|
||||||
tick={{
|
|
||||||
fontSize: 'var(--font-size-sm, 12px)',
|
|
||||||
fill: 'var(--color-text-secondary, #4E606F)',
|
|
||||||
}}
|
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
width={30}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
|
||||||
content={<ChartTooltip />}
|
|
||||||
cursor={{stroke: 'var(--color-border, rgba(5, 54, 89, 0.1))'}}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="linear"
|
|
||||||
dataKey="allUsers"
|
|
||||||
name="All Users"
|
|
||||||
stroke={chartColors.allUsers}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="linear"
|
|
||||||
dataKey="desktop"
|
|
||||||
name="Desktop"
|
|
||||||
stroke={chartColors.desktop}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="linear"
|
|
||||||
dataKey="mobile"
|
|
||||||
name="Mobile"
|
|
||||||
stroke={chartColors.mobile}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
<HStack gap={6} vAlign="center">
|
|
||||||
<ChartLegendItem color={chartColors.allUsers} label="All Users" />
|
|
||||||
<ChartLegendItem color={chartColors.desktop} label="Desktop" />
|
|
||||||
<ChartLegendItem color={chartColors.mobile} label="Mobile" />
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Sparkline({data}: {data: number[]}) {
|
|
||||||
const chartData = data.map((v, i) => ({i, v}));
|
|
||||||
return (
|
|
||||||
<ResponsiveContainer width="100%" height={40}>
|
|
||||||
<LineChart data={chartData}>
|
|
||||||
<Line
|
|
||||||
type="linear"
|
|
||||||
dataKey="v"
|
|
||||||
stroke="var(--color-data-categorical-blue, #0171E3)"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
dot={false}
|
|
||||||
isAnimationActive={false}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============= CARD COMPONENTS =============
|
|
||||||
|
|
||||||
function MetricCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
change,
|
|
||||||
positive,
|
|
||||||
sparkline,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
change: string;
|
|
||||||
positive: boolean;
|
|
||||||
sparkline: number[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<VStack gap={2}>
|
|
||||||
<Heading level={4}>{label}</Heading>
|
|
||||||
<HStack gap={2} vAlign="center">
|
|
||||||
<Heading level={2}>{value}</Heading>
|
|
||||||
<HStack gap={1} vAlign="center">
|
|
||||||
{positive ? (
|
|
||||||
<Icon icon={ArrowUpIcon} size="xsm" color="success" />
|
|
||||||
) : (
|
|
||||||
<Icon icon={ArrowDownIcon} size="xsm" color="error" />
|
|
||||||
)}
|
|
||||||
<Text type="body" color="secondary">
|
|
||||||
{change}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
Last 30 days vs. Previous
|
|
||||||
</Text>
|
|
||||||
<Sparkline data={sparkline} />
|
|
||||||
</VStack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StackedBarCard({
|
|
||||||
title,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
data: Array<{label: string; value: number; color: string}>;
|
|
||||||
}) {
|
|
||||||
const total = data.reduce((sum, d) => sum + d.value, 0);
|
|
||||||
// Recharts needs a single data row with each segment as a separate key
|
|
||||||
const chartData = [Object.fromEntries(data.map(d => [d.label, d.value]))];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<VStack gap={4}>
|
|
||||||
<Heading level={4}>{title}</Heading>
|
|
||||||
<ResponsiveContainer width="100%" height={24}>
|
|
||||||
<BarChart
|
|
||||||
data={chartData}
|
|
||||||
layout="vertical"
|
|
||||||
margin={{top: 0, right: 0, bottom: 0, left: 0}}
|
|
||||||
barCategoryGap={0}>
|
|
||||||
<XAxis type="number" hide />
|
|
||||||
<YAxis type="category" hide />
|
|
||||||
{data.map((d, i) => (
|
|
||||||
<Bar
|
|
||||||
key={d.label}
|
|
||||||
dataKey={d.label}
|
|
||||||
stackId="stack"
|
|
||||||
fill={d.color}
|
|
||||||
isAnimationActive={false}
|
|
||||||
radius={
|
|
||||||
i === 0
|
|
||||||
? [4, 0, 0, 4]
|
|
||||||
: i === data.length - 1
|
|
||||||
? [0, 4, 4, 0]
|
|
||||||
: [0, 0, 0, 0]
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
{/* Legend */}
|
|
||||||
<HStack gap={4} wrap="wrap">
|
|
||||||
{data.map(d => (
|
|
||||||
<VStack key={d.label} gap={0}>
|
|
||||||
<HStack gap={2} vAlign="center">
|
|
||||||
<Icon icon={StopIcon} size="xsm" style={{color: d.color}} />
|
|
||||||
<Text type="supporting">{d.label}</Text>
|
|
||||||
</HStack>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
{d.value} - {((d.value / total) * 100).toFixed(2)}%
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
))}
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============= TABLE COMPONENTS =============
|
|
||||||
|
|
||||||
function TableCard<T extends {id: string}>({
|
|
||||||
title,
|
|
||||||
linkLabel,
|
|
||||||
linkHref,
|
|
||||||
data,
|
|
||||||
columns,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
linkLabel: string;
|
|
||||||
linkHref: string;
|
|
||||||
data: T[];
|
|
||||||
columns: TableColumn<T>[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<VStack gap={6}>
|
|
||||||
<HStack hAlign="between" vAlign="center">
|
|
||||||
<Heading level={4}>{title}</Heading>
|
|
||||||
<Link href={linkHref}>{linkLabel}</Link>
|
|
||||||
</HStack>
|
|
||||||
<Table<T>
|
|
||||||
data={data}
|
|
||||||
columns={columns}
|
|
||||||
idKey="id"
|
|
||||||
density="compact"
|
|
||||||
dividers="rows"
|
|
||||||
hasHover
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============= SIDENAV =============
|
|
||||||
|
|
||||||
// ============= MAIN COMPONENT =============
|
|
||||||
|
|
||||||
export default function DashboardTemplate() {
|
|
||||||
return (
|
|
||||||
<Layout
|
|
||||||
height="auto"
|
|
||||||
content={
|
|
||||||
<LayoutContent padding={6}>
|
|
||||||
<VStack gap={6}>
|
|
||||||
{/* Active Users Chart */}
|
|
||||||
<VStack gap={6}>
|
|
||||||
<HStack hAlign="between" vAlign="center">
|
|
||||||
<Heading level={3}>Active users</Heading>
|
|
||||||
<Button
|
|
||||||
label="Reload"
|
|
||||||
variant="secondary"
|
|
||||||
size="md"
|
|
||||||
icon={<Icon icon={ArrowPathIcon} size="sm" />}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
<ActiveUsersChart />
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
{/* Metric Cards */}
|
|
||||||
<Grid columns={{minWidth: 320, repeat: 'fit'}} gap={4}>
|
|
||||||
{[0, 2].map(start => (
|
|
||||||
<Grid
|
|
||||||
key={start}
|
|
||||||
columns={{minWidth: 240, repeat: 'fit'}}
|
|
||||||
gap={4}>
|
|
||||||
{metrics.slice(start, start + 2).map((m, i) => (
|
|
||||||
<MetricCard
|
|
||||||
key={m.label}
|
|
||||||
{...m}
|
|
||||||
sparkline={sparklines[start + i]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Demographics */}
|
|
||||||
<HStack hAlign="between" vAlign="center">
|
|
||||||
<Heading level={3}>Demographics</Heading>
|
|
||||||
<Button label="View more" variant="secondary" size="md" />
|
|
||||||
</HStack>
|
|
||||||
<Grid columns={{minWidth: 320, repeat: 'fit'}} gap={4}>
|
|
||||||
<StackedBarCard title="Region" data={regionData} />
|
|
||||||
<StackedBarCard title="Role" data={roleData} />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
{/* Engagement */}
|
|
||||||
<HStack hAlign="between" vAlign="center">
|
|
||||||
<Heading level={3}>Engagement</Heading>
|
|
||||||
<Button label="View more" variant="secondary" size="md" />
|
|
||||||
</HStack>
|
|
||||||
<Grid columns={{minWidth: 320, repeat: 'fit'}} gap={4}>
|
|
||||||
<TableCard
|
|
||||||
title="Top pages"
|
|
||||||
linkLabel="All pages"
|
|
||||||
linkHref="#"
|
|
||||||
data={topPagesData}
|
|
||||||
columns={topPagesColumns}
|
|
||||||
/>
|
|
||||||
<TableCard
|
|
||||||
title="Top events"
|
|
||||||
linkLabel="All events"
|
|
||||||
linkHref="#"
|
|
||||||
data={topEventsData}
|
|
||||||
columns={topEventsColumns}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</VStack>
|
|
||||||
</LayoutContent>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import {useState, type CSSProperties} from 'react';
|
|
||||||
import {ShieldCheckIcon} from '@heroicons/react/24/outline';
|
|
||||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
|
||||||
import {Center} from '@astryxdesign/core/Center';
|
|
||||||
import {Text} from '@astryxdesign/core/Text';
|
|
||||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
|
||||||
import {Button} from '@astryxdesign/core/Button';
|
|
||||||
import {Card} from '@astryxdesign/core/Card';
|
|
||||||
import {Section} from '@astryxdesign/core/Section';
|
|
||||||
import {Link} from '@astryxdesign/core/Link';
|
|
||||||
import {Divider} from '@astryxdesign/core/Divider';
|
|
||||||
import {Icon} from '@astryxdesign/core/Icon';
|
|
||||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Styles
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const BG_URL = 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20400%20300%22%20preserveAspectRatio%3D%22xMidYMid%20slice%22%3E%3Crect%20width%3D%22400%22%20height%3D%22300%22%20fill%3D%22%23f5f6f8%22%2F%3E%3Cg%20transform%3D%22translate%28200%20150%29%22%20fill%3D%22none%22%20stroke%3D%22%23c2cad6%22%20stroke-width%3D%225%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Crect%20x%3D%22-44%22%20y%3D%22-44%22%20width%3D%2288%22%20height%3D%2288%22%20rx%3D%2216%22%2F%3E%3Ccircle%20cx%3D%2218%22%20cy%3D%22-18%22%20r%3D%222.5%22%20fill%3D%22%23c2cad6%22%20stroke%3D%22none%22%2F%3E%3Cpath%20d%3D%22M-34%2030%20L-8%200%20L10%2018%20L20%208%20L34%2024%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E';
|
|
||||||
|
|
||||||
const pageStyle: CSSProperties = {
|
|
||||||
minHeight: '100%',
|
|
||||||
backgroundImage: `url(${BG_URL})`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
padding: 'var(--spacing-6)',
|
|
||||||
};
|
|
||||||
|
|
||||||
type SSOProvider = {
|
|
||||||
name: string;
|
|
||||||
abbr: string;
|
|
||||||
};
|
|
||||||
const SSO_PROVIDERS: Record<string, SSOProvider> = {
|
|
||||||
'google.com': {name: 'Google Workspace', abbr: 'G'},
|
|
||||||
'microsoft.com': {name: 'Microsoft Entra ID', abbr: 'M'},
|
|
||||||
'okta.com': {name: 'Okta', abbr: 'O'},
|
|
||||||
'meta.com': {name: 'Meta SSO', abbr: 'M'},
|
|
||||||
'apple.com': {name: 'Apple Business', abbr: 'A'},
|
|
||||||
};
|
|
||||||
|
|
||||||
function getProvider(email: string) {
|
|
||||||
const domain = email.split('@')[1]?.toLowerCase();
|
|
||||||
return domain ? (SSO_PROVIDERS[domain] ?? null) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidEmail(email: string) {
|
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Component
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type Step = 'email' | 'sso-confirm' | 'password-fallback';
|
|
||||||
|
|
||||||
export default function LoginSSO() {
|
|
||||||
const [step, setStep] = useState<Step>('email');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [loginFailed, setLoginFailed] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const provider = getProvider(email);
|
|
||||||
const emailValid = isValidEmail(email);
|
|
||||||
|
|
||||||
const handleContinue = () => {
|
|
||||||
if (!emailValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (provider) {
|
|
||||||
setStep('sso-confirm');
|
|
||||||
} else {
|
|
||||||
setStep('password-fallback');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
setStep('email');
|
|
||||||
setLoginFailed(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSignIn = () => {
|
|
||||||
if (!password) {
|
|
||||||
setLoginFailed(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLoading(true);
|
|
||||||
setLoginFailed(false);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
setLoginFailed(true);
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Center axis="both" style={pageStyle}>
|
|
||||||
<Card padding={8} width="100%" maxWidth={400}>
|
|
||||||
<VStack gap={4} hAlign="stretch">
|
|
||||||
{/* ── Step 1: Email entry ── */}
|
|
||||||
{step === 'email' && (
|
|
||||||
<>
|
|
||||||
<VStack gap={1} hAlign="center">
|
|
||||||
<Text type="display-1" as="h2">
|
|
||||||
Welcome back
|
|
||||||
</Text>
|
|
||||||
<Text type="body" color="secondary" size="sm">
|
|
||||||
Enter your details to sign in to your account
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<VStack gap={2}>
|
|
||||||
<TextInput
|
|
||||||
label="Work email"
|
|
||||||
isLabelHidden
|
|
||||||
type="email"
|
|
||||||
placeholder="you@company.com"
|
|
||||||
value={email}
|
|
||||||
onChange={setEmail}
|
|
||||||
size="lg"
|
|
||||||
onKeyDown={(e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
handleContinue();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Password"
|
|
||||||
isLabelHidden
|
|
||||||
type="password"
|
|
||||||
placeholder="Password"
|
|
||||||
value={password}
|
|
||||||
onChange={setPassword}
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<Link href="#" size="sm" color="secondary" type="supporting">
|
|
||||||
Having trouble signing in?
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
label="Sign in"
|
|
||||||
variant="primary"
|
|
||||||
size="lg"
|
|
||||||
onClick={handleContinue}
|
|
||||||
isDisabled={!emailValid}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider label="Or sign in with" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
label="Continue with SSO"
|
|
||||||
variant="secondary"
|
|
||||||
size="lg"
|
|
||||||
onClick={handleContinue}
|
|
||||||
isDisabled={!emailValid}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<VStack hAlign="center">
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
Don't have an account?{' '}
|
|
||||||
<Link href="#" type="supporting">
|
|
||||||
Request access
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Step 2a: SSO provider detected ── */}
|
|
||||||
{step === 'sso-confirm' && provider && (
|
|
||||||
<>
|
|
||||||
<VStack gap={2} hAlign="center">
|
|
||||||
<Avatar name={provider.name} size={48} />
|
|
||||||
<Text type="display-3" as="h2">
|
|
||||||
Sign in with {provider.name}
|
|
||||||
</Text>
|
|
||||||
<Text type="body" color="secondary" size="sm">
|
|
||||||
You will be redirected back after signing in.
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<Card padding={0}>
|
|
||||||
<Section variant="muted" padding={4}>
|
|
||||||
<HStack gap={2} vAlign="center">
|
|
||||||
<Icon icon={ShieldCheckIcon} color="secondary" />
|
|
||||||
<VStack gap={0}>
|
|
||||||
<Text type="label">{provider.name}</Text>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
{email}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
</Section>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<VStack gap={3}>
|
|
||||||
<Button
|
|
||||||
label={`Continue with ${provider.name}`}
|
|
||||||
variant="primary"
|
|
||||||
size="lg"
|
|
||||||
isLoading={isLoading}
|
|
||||||
onClick={() => setIsLoading(true)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
label="Use a different email"
|
|
||||||
variant="ghost"
|
|
||||||
size="lg"
|
|
||||||
onClick={handleBack}
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Step 2b: No SSO — password fallback ── */}
|
|
||||||
{step === 'password-fallback' && (
|
|
||||||
<>
|
|
||||||
<VStack gap={1} hAlign="center">
|
|
||||||
<Text type="display-1" as="h2">
|
|
||||||
Welcome back
|
|
||||||
</Text>
|
|
||||||
<Text type="body" color="secondary" size="sm">
|
|
||||||
{email}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<VStack gap={4}>
|
|
||||||
<VStack gap={1}>
|
|
||||||
<TextInput
|
|
||||||
label="Password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
size="lg"
|
|
||||||
onChange={(v: string) => {
|
|
||||||
setPassword(v);
|
|
||||||
setLoginFailed(false);
|
|
||||||
}}
|
|
||||||
status={
|
|
||||||
loginFailed
|
|
||||||
? {
|
|
||||||
type: 'error',
|
|
||||||
message: 'Incorrect password. Try again.',
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{loginFailed && (
|
|
||||||
<VStack hAlign="end">
|
|
||||||
<Link
|
|
||||||
href="#"
|
|
||||||
size="sm"
|
|
||||||
color="secondary"
|
|
||||||
type="supporting">
|
|
||||||
Forgot password?
|
|
||||||
</Link>
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
label="Sign in"
|
|
||||||
variant="primary"
|
|
||||||
size="lg"
|
|
||||||
isLoading={isLoading}
|
|
||||||
onClick={handleSignIn}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
label="Use a different email"
|
|
||||||
variant="ghost"
|
|
||||||
size="lg"
|
|
||||||
onClick={handleBack}
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</Card>
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,855 +0,0 @@
|
|||||||
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import {useState, type CSSProperties} from 'react';
|
|
||||||
import {useMediaQuery} from '@astryxdesign/core/hooks';
|
|
||||||
import {
|
|
||||||
VStack,
|
|
||||||
HStack,
|
|
||||||
StackItem,
|
|
||||||
Layout,
|
|
||||||
LayoutContent,
|
|
||||||
LayoutPanel,
|
|
||||||
} from '@astryxdesign/core/Layout';
|
|
||||||
import {List, ListItem} from '@astryxdesign/core/List';
|
|
||||||
import {Toolbar} from '@astryxdesign/core/Toolbar';
|
|
||||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
|
||||||
import {Link} from '@astryxdesign/core/Link';
|
|
||||||
import {Button} from '@astryxdesign/core/Button';
|
|
||||||
import {Selector} from '@astryxdesign/core/Selector';
|
|
||||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
|
||||||
import {Card} from '@astryxdesign/core/Card';
|
|
||||||
import {Switch} from '@astryxdesign/core/Switch';
|
|
||||||
import {Divider} from '@astryxdesign/core/Divider';
|
|
||||||
import {TabList, Tab} from '@astryxdesign/core/TabList';
|
|
||||||
import {Badge} from '@astryxdesign/core/Badge';
|
|
||||||
import {Icon} from '@astryxdesign/core/Icon';
|
|
||||||
import {Center} from '@astryxdesign/core/Center';
|
|
||||||
import {
|
|
||||||
UserIcon,
|
|
||||||
LockClosedIcon,
|
|
||||||
ShieldCheckIcon,
|
|
||||||
BellIcon,
|
|
||||||
DocumentTextIcon,
|
|
||||||
CreditCardIcon,
|
|
||||||
GlobeAltIcon,
|
|
||||||
BriefcaseIcon,
|
|
||||||
WrenchScrewdriverIcon,
|
|
||||||
ComputerDesktopIcon,
|
|
||||||
PencilSquareIcon,
|
|
||||||
ShareIcon,
|
|
||||||
ArrowLeftIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
|
|
||||||
// Anchor the page to the viewport height so the sidebar + content fill the
|
|
||||||
// screen. Layout height="fill" is min-height:100% which collapses when the
|
|
||||||
// host container is content-sized; Layout has no viewport-height prop.
|
|
||||||
const fillViewport: CSSProperties = {
|
|
||||||
minHeight: '100dvh',
|
|
||||||
};
|
|
||||||
const iconBox: CSSProperties = {
|
|
||||||
borderRadius: 'var(--radius-container)',
|
|
||||||
backgroundColor: 'var(--color-background-surface)',
|
|
||||||
flexShrink: 0,
|
|
||||||
};
|
|
||||||
const rowPadding: CSSProperties = {
|
|
||||||
paddingBlock: 'var(--spacing-4)',
|
|
||||||
};
|
|
||||||
const sideNavPadding: CSSProperties = {
|
|
||||||
paddingBlock: 'var(--spacing-4)',
|
|
||||||
paddingInline: 'var(--spacing-3)',
|
|
||||||
};
|
|
||||||
const sideNavHeading: CSSProperties = {
|
|
||||||
marginInline: 'var(--spacing-4)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
|
||||||
{label: 'Personal information', icon: UserIcon},
|
|
||||||
{label: 'Login & security', icon: LockClosedIcon},
|
|
||||||
{label: 'Privacy', icon: ShieldCheckIcon},
|
|
||||||
{label: 'Notifications', icon: BellIcon},
|
|
||||||
{label: 'Taxes', icon: DocumentTextIcon},
|
|
||||||
{label: 'Payments', icon: CreditCardIcon},
|
|
||||||
{label: 'Languages & currency', icon: GlobeAltIcon},
|
|
||||||
{label: 'Travel for work', icon: BriefcaseIcon},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Section title shown beside the mobile back button (matches each section's
|
|
||||||
// in-content heading, which is hidden on mobile to avoid a duplicate).
|
|
||||||
const SECTION_TITLES: Record<string, string> = {
|
|
||||||
'Personal information': 'Personal info',
|
|
||||||
'Login & security': 'Login & security',
|
|
||||||
Privacy: 'Privacy',
|
|
||||||
'Languages & currency': 'Languages & currency',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface InfoRow {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
action: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOGIN_ROWS: InfoRow[] = [
|
|
||||||
{label: 'Password', value: 'Not created', action: 'Create'},
|
|
||||||
];
|
|
||||||
|
|
||||||
const SOCIAL_ROWS: InfoRow[] = [
|
|
||||||
{label: 'Google', value: 'Connected', action: 'Disconnect'},
|
|
||||||
];
|
|
||||||
|
|
||||||
const DEVICE_ROWS: {
|
|
||||||
label: string;
|
|
||||||
badge?: string;
|
|
||||||
location: string;
|
|
||||||
action?: string;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
label: 'OS X 10.15.7 · Chrome',
|
|
||||||
badge: 'CURRENT SESSION',
|
|
||||||
location: 'McKinney, Texas · March 30, 2026 at 19:31',
|
|
||||||
},
|
|
||||||
{label: 'Session', location: 'August 9, 2023 at 04:19', action: 'Log out'},
|
|
||||||
{
|
|
||||||
label: 'OS X 10.15.7 · unknown',
|
|
||||||
location: 'Sunnyvale, California · April 14, 2023 at 17:47',
|
|
||||||
action: 'Log out',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function InfoRowItem({label, value, action}: InfoRow) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<HStack hAlign="between" vAlign="start" style={rowPadding}>
|
|
||||||
<VStack gap={0}>
|
|
||||||
<Text type="body" weight="semibold" display="block">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Text type="supporting" color="secondary" display="block">
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
{action && <Link href="#">{action}</Link>}
|
|
||||||
</HStack>
|
|
||||||
<Divider />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExpandableRowProps {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
isExpanded: boolean;
|
|
||||||
onEdit: () => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
onSave: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExpandableRow({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
children,
|
|
||||||
isExpanded,
|
|
||||||
onEdit,
|
|
||||||
onCancel,
|
|
||||||
onSave,
|
|
||||||
}: ExpandableRowProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{isExpanded ? (
|
|
||||||
<VStack gap={4} style={rowPadding}>
|
|
||||||
<Text type="body" weight="semibold" display="block">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
{children}
|
|
||||||
<HStack gap={2}>
|
|
||||||
<Button label="Save" variant="primary" onClick={onSave} />
|
|
||||||
<Button label="Cancel" variant="ghost" onClick={onCancel} />
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
) : (
|
|
||||||
<HStack hAlign="between" vAlign="start" style={rowPadding}>
|
|
||||||
<VStack gap={0}>
|
|
||||||
<Text type="body" weight="semibold" display="block">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Text type="supporting" color="secondary" display="block">
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
<Link
|
|
||||||
href="#"
|
|
||||||
onClick={(e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onEdit();
|
|
||||||
}}>
|
|
||||||
Edit
|
|
||||||
</Link>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
<Divider />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const LANGUAGES = [
|
|
||||||
{label: 'English (Canada)', value: 'en-CA'},
|
|
||||||
{label: 'English (US)', value: 'en-US'},
|
|
||||||
{label: 'French', value: 'fr'},
|
|
||||||
{label: 'Spanish', value: 'es'},
|
|
||||||
{label: 'German', value: 'de'},
|
|
||||||
{label: 'Japanese', value: 'ja'},
|
|
||||||
];
|
|
||||||
|
|
||||||
const CURRENCIES = [
|
|
||||||
{label: 'Canadian dollar (CAD)', value: 'CAD'},
|
|
||||||
{label: 'US dollar (USD)', value: 'USD'},
|
|
||||||
{label: 'Euro (EUR)', value: 'EUR'},
|
|
||||||
{label: 'British pound (GBP)', value: 'GBP'},
|
|
||||||
{label: 'Japanese yen (JPY)', value: 'JPY'},
|
|
||||||
];
|
|
||||||
|
|
||||||
const TIMEZONES = [
|
|
||||||
{label: '(GMT-05:00) Eastern Time (US & Canada)', value: 'ET'},
|
|
||||||
{label: '(GMT-06:00) Central Time (US & Canada)', value: 'CT'},
|
|
||||||
{label: '(GMT-07:00) Mountain Time (US & Canada)', value: 'MT'},
|
|
||||||
{label: '(GMT-08:00) Pacific Time (US & Canada)', value: 'PT'},
|
|
||||||
{label: '(GMT+00:00) UTC', value: 'UTC'},
|
|
||||||
{label: '(GMT+01:00) London', value: 'GMT+1'},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SettingsSecurityTemplate() {
|
|
||||||
const isNarrow = useMediaQuery('(max-width: 768px)');
|
|
||||||
// Mobile is a master→detail drill-down: 'nav' shows the menu, 'detail' shows
|
|
||||||
// the selected section with a back button. Desktop shows both side-by-side.
|
|
||||||
const [mobileView, setMobileView] = useState<'nav' | 'detail'>('nav');
|
|
||||||
const [activeNav, setActiveNav] = useState('Personal information');
|
|
||||||
const [activeTab, setActiveTab] = useState('login');
|
|
||||||
const [readReceipts, setReadReceipts] = useState(true);
|
|
||||||
const [searchEngines, setSearchEngines] = useState(true);
|
|
||||||
const [showCity, setShowCity] = useState(true);
|
|
||||||
const [showTripType, setShowTripType] = useState(true);
|
|
||||||
const [showStayLength, setShowStayLength] = useState(true);
|
|
||||||
const [showServices, setShowServices] = useState(true);
|
|
||||||
const [aiFeatures, setAiFeatures] = useState(true);
|
|
||||||
|
|
||||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
|
||||||
const [language, setLanguage] = useState('en-CA');
|
|
||||||
const [currency, setCurrency] = useState('CAD');
|
|
||||||
const [timezone, setTimezone] = useState('ET');
|
|
||||||
|
|
||||||
const [legalName, setLegalName] = useState('Alex Johnson');
|
|
||||||
const [preferredName, setPreferredName] = useState('');
|
|
||||||
const [email, setEmail] = useState('a***n@example.com');
|
|
||||||
const [phone, setPhone] = useState('+1 ***-***-0123');
|
|
||||||
const [address, setAddress] = useState('');
|
|
||||||
const [mailingAddress, setMailingAddress] = useState('');
|
|
||||||
const [emergencyContact, setEmergencyContact] = useState('Provided');
|
|
||||||
|
|
||||||
// Selecting a nav item also drills into the detail view on mobile.
|
|
||||||
const selectNav = (label: string) => {
|
|
||||||
setActiveNav(label);
|
|
||||||
setMobileView('detail');
|
|
||||||
};
|
|
||||||
|
|
||||||
const navList = (
|
|
||||||
<VStack gap={4} style={sideNavPadding}>
|
|
||||||
<Heading level={2} style={sideNavHeading}>
|
|
||||||
Account settings
|
|
||||||
</Heading>
|
|
||||||
<List density="spacious">
|
|
||||||
{NAV_ITEMS.map(item => (
|
|
||||||
<ListItem
|
|
||||||
key={item.label}
|
|
||||||
label={item.label}
|
|
||||||
startContent={<Icon icon={item.icon} />}
|
|
||||||
endContent={
|
|
||||||
isNarrow ? (
|
|
||||||
<Icon icon={ChevronRightIcon} size="sm" color="secondary" />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
isSelected={!isNarrow && activeNav === item.label}
|
|
||||||
onClick={() => selectNav(item.label)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
<Divider />
|
|
||||||
<List density="spacious">
|
|
||||||
<ListItem
|
|
||||||
label="Professional hosting tools"
|
|
||||||
startContent={<Icon icon={WrenchScrewdriverIcon} />}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>
|
|
||||||
</List>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mobile, nav view: show only the menu (full width, no sidebar slot).
|
|
||||||
if (isNarrow && mobileView === 'nav') {
|
|
||||||
return (
|
|
||||||
<Layout
|
|
||||||
height="fill"
|
|
||||||
style={fillViewport}
|
|
||||||
content={<LayoutContent padding={2}>{navList}</LayoutContent>}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout
|
|
||||||
height="fill"
|
|
||||||
contentWidth={1200}
|
|
||||||
style={fillViewport}
|
|
||||||
start={
|
|
||||||
isNarrow ? undefined : (
|
|
||||||
<LayoutPanel hasDivider padding={0}>
|
|
||||||
{navList}
|
|
||||||
</LayoutPanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
content={
|
|
||||||
<LayoutContent padding={4}>
|
|
||||||
<VStack gap={0}>
|
|
||||||
{/* Mobile detail view: a back button sits beside the section title
|
|
||||||
(the per-section headings below are hidden on mobile). Toolbar's
|
|
||||||
start slot edge-compensates the ghost button so its icon aligns
|
|
||||||
flush with the content edge. */}
|
|
||||||
{isNarrow && (
|
|
||||||
<Toolbar
|
|
||||||
label={`Back to Account settings — ${SECTION_TITLES[activeNav]}`}
|
|
||||||
gap={2}
|
|
||||||
startContent={
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
label="Back to Account settings"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
isIconOnly
|
|
||||||
icon={<Icon icon={ArrowLeftIcon} size="sm" />}
|
|
||||||
onClick={() => setMobileView('nav')}
|
|
||||||
/>
|
|
||||||
<Heading level={2}>
|
|
||||||
{SECTION_TITLES[activeNav]}
|
|
||||||
</Heading>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeNav === 'Login & security' && (
|
|
||||||
<VStack gap={6}>
|
|
||||||
{!isNarrow && (
|
|
||||||
<Heading level={2}>Login & security</Heading>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TabList
|
|
||||||
value={activeTab}
|
|
||||||
onChange={setActiveTab}
|
|
||||||
hasDivider>
|
|
||||||
<Tab value="login" label="Login" />
|
|
||||||
<Tab value="shared" label="Shared access" />
|
|
||||||
</TabList>
|
|
||||||
|
|
||||||
{activeTab === 'login' && (
|
|
||||||
<VStack gap={8}>
|
|
||||||
<VStack gap={0}>
|
|
||||||
<Heading level={3}>Login</Heading>
|
|
||||||
<Divider />
|
|
||||||
{LOGIN_ROWS.map(row => (
|
|
||||||
<InfoRowItem key={row.label} {...row} />
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<VStack gap={0}>
|
|
||||||
<Heading level={3}>Social accounts</Heading>
|
|
||||||
<Divider />
|
|
||||||
{SOCIAL_ROWS.map(row => (
|
|
||||||
<InfoRowItem key={row.label} {...row} />
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<VStack gap={0}>
|
|
||||||
<Heading level={3}>Device history</Heading>
|
|
||||||
<Divider />
|
|
||||||
{DEVICE_ROWS.map((device, i) => (
|
|
||||||
<HStack
|
|
||||||
key={i}
|
|
||||||
gap={3}
|
|
||||||
vAlign="start"
|
|
||||||
style={rowPadding}>
|
|
||||||
<Icon icon={ComputerDesktopIcon} />
|
|
||||||
<StackItem size="fill">
|
|
||||||
<VStack gap={0}>
|
|
||||||
<HStack gap={2} vAlign="center">
|
|
||||||
<Text type="body" weight="semibold">
|
|
||||||
{device.label}
|
|
||||||
</Text>
|
|
||||||
{device.badge && (
|
|
||||||
<Badge label={device.badge} />
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
<Text
|
|
||||||
type="supporting"
|
|
||||||
color="secondary"
|
|
||||||
display="block">
|
|
||||||
{device.location}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</StackItem>
|
|
||||||
{device.action && (
|
|
||||||
<Link href="#">{device.action}</Link>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
))}
|
|
||||||
<Divider />
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<VStack gap={0}>
|
|
||||||
<Heading level={3}>Account</Heading>
|
|
||||||
<Divider />
|
|
||||||
<HStack
|
|
||||||
hAlign="between"
|
|
||||||
vAlign="start"
|
|
||||||
style={rowPadding}>
|
|
||||||
<VStack gap={0}>
|
|
||||||
<Text
|
|
||||||
type="body"
|
|
||||||
weight="semibold"
|
|
||||||
display="block">
|
|
||||||
Deactivate your account
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
type="supporting"
|
|
||||||
color="secondary"
|
|
||||||
display="block">
|
|
||||||
This action cannot be undone
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
<Link href="#">Deactivate</Link>
|
|
||||||
</HStack>
|
|
||||||
<Divider />
|
|
||||||
</VStack>
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'shared' && (
|
|
||||||
<VStack gap={8}>
|
|
||||||
<VStack gap={2}>
|
|
||||||
<Heading level={3}>Shared access</Heading>
|
|
||||||
<Divider />
|
|
||||||
<Text type="body" color="secondary">
|
|
||||||
Review each request carefully before approving access.
|
|
||||||
We'll email your employee or co-worker a 4-digit
|
|
||||||
code that lets them log into your account with their
|
|
||||||
trusted device.
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<Card variant="muted">
|
|
||||||
<HStack gap={4} vAlign="start">
|
|
||||||
<Center
|
|
||||||
width={48}
|
|
||||||
height={48}
|
|
||||||
style={iconBox}>
|
|
||||||
<Icon icon={LockClosedIcon} />
|
|
||||||
</Center>
|
|
||||||
<VStack gap={1}>
|
|
||||||
<Text type="body" weight="bold">
|
|
||||||
Adding devices from people you trust
|
|
||||||
</Text>
|
|
||||||
<Text type="body" color="secondary">
|
|
||||||
When you approve a request, you grant someone full
|
|
||||||
access to your account. They'll be able to
|
|
||||||
change reservations and send messages on your
|
|
||||||
behalf.
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
</Card>
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeNav === 'Languages & currency' && (
|
|
||||||
<VStack gap={6}>
|
|
||||||
{!isNarrow && (
|
|
||||||
<Heading level={2}>Languages & currency</Heading>
|
|
||||||
)}
|
|
||||||
<VStack gap={0}>
|
|
||||||
<ExpandableRow
|
|
||||||
label="Preferred language"
|
|
||||||
value={
|
|
||||||
LANGUAGES.find(l => l.value === language)?.label ??
|
|
||||||
language
|
|
||||||
}
|
|
||||||
isExpanded={expandedRow === 'language'}
|
|
||||||
onEdit={() => setExpandedRow('language')}
|
|
||||||
onCancel={() => setExpandedRow(null)}
|
|
||||||
onSave={() => setExpandedRow(null)}>
|
|
||||||
<Selector
|
|
||||||
label="Language"
|
|
||||||
isLabelHidden
|
|
||||||
size="lg"
|
|
||||||
value={language}
|
|
||||||
onChange={setLanguage}
|
|
||||||
options={LANGUAGES}
|
|
||||||
/>
|
|
||||||
</ExpandableRow>
|
|
||||||
<ExpandableRow
|
|
||||||
label="Preferred currency"
|
|
||||||
value={
|
|
||||||
CURRENCIES.find(c => c.value === currency)?.label ??
|
|
||||||
currency
|
|
||||||
}
|
|
||||||
isExpanded={expandedRow === 'currency'}
|
|
||||||
onEdit={() => setExpandedRow('currency')}
|
|
||||||
onCancel={() => setExpandedRow(null)}
|
|
||||||
onSave={() => setExpandedRow(null)}>
|
|
||||||
<Selector
|
|
||||||
label="Currency"
|
|
||||||
isLabelHidden
|
|
||||||
size="lg"
|
|
||||||
value={currency}
|
|
||||||
onChange={setCurrency}
|
|
||||||
options={CURRENCIES}
|
|
||||||
/>
|
|
||||||
</ExpandableRow>
|
|
||||||
<ExpandableRow
|
|
||||||
label="Time zone"
|
|
||||||
value={
|
|
||||||
TIMEZONES.find(t => t.value === timezone)?.label ??
|
|
||||||
timezone
|
|
||||||
}
|
|
||||||
isExpanded={expandedRow === 'timezone'}
|
|
||||||
onEdit={() => setExpandedRow('timezone')}
|
|
||||||
onCancel={() => setExpandedRow(null)}
|
|
||||||
onSave={() => setExpandedRow(null)}>
|
|
||||||
<Selector
|
|
||||||
label="Time zone"
|
|
||||||
isLabelHidden
|
|
||||||
size="lg"
|
|
||||||
value={timezone}
|
|
||||||
onChange={setTimezone}
|
|
||||||
options={TIMEZONES}
|
|
||||||
/>
|
|
||||||
</ExpandableRow>
|
|
||||||
</VStack>
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeNav === 'Personal information' && (
|
|
||||||
<VStack gap={6}>
|
|
||||||
{!isNarrow && <Heading level={2}>Personal info</Heading>}
|
|
||||||
<VStack gap={0}>
|
|
||||||
<ExpandableRow
|
|
||||||
label="Legal name"
|
|
||||||
value={legalName}
|
|
||||||
isExpanded={expandedRow === 'legalName'}
|
|
||||||
onEdit={() => setExpandedRow('legalName')}
|
|
||||||
onCancel={() => setExpandedRow(null)}
|
|
||||||
onSave={() => setExpandedRow(null)}>
|
|
||||||
<TextInput
|
|
||||||
label="Legal name"
|
|
||||||
isLabelHidden
|
|
||||||
value={legalName}
|
|
||||||
onChange={setLegalName}
|
|
||||||
/>
|
|
||||||
</ExpandableRow>
|
|
||||||
<ExpandableRow
|
|
||||||
label="Preferred first name"
|
|
||||||
value={preferredName || 'Not provided'}
|
|
||||||
isExpanded={expandedRow === 'preferredName'}
|
|
||||||
onEdit={() => setExpandedRow('preferredName')}
|
|
||||||
onCancel={() => setExpandedRow(null)}
|
|
||||||
onSave={() => setExpandedRow(null)}>
|
|
||||||
<TextInput
|
|
||||||
label="Preferred first name"
|
|
||||||
isLabelHidden
|
|
||||||
value={preferredName}
|
|
||||||
onChange={setPreferredName}
|
|
||||||
/>
|
|
||||||
</ExpandableRow>
|
|
||||||
<ExpandableRow
|
|
||||||
label="Email address"
|
|
||||||
value={email}
|
|
||||||
isExpanded={expandedRow === 'email'}
|
|
||||||
onEdit={() => setExpandedRow('email')}
|
|
||||||
onCancel={() => setExpandedRow(null)}
|
|
||||||
onSave={() => setExpandedRow(null)}>
|
|
||||||
<TextInput
|
|
||||||
label="Email address"
|
|
||||||
isLabelHidden
|
|
||||||
value={email}
|
|
||||||
onChange={setEmail}
|
|
||||||
/>
|
|
||||||
</ExpandableRow>
|
|
||||||
<ExpandableRow
|
|
||||||
label="Phone number"
|
|
||||||
value={phone}
|
|
||||||
isExpanded={expandedRow === 'phone'}
|
|
||||||
onEdit={() => setExpandedRow('phone')}
|
|
||||||
onCancel={() => setExpandedRow(null)}
|
|
||||||
onSave={() => setExpandedRow(null)}>
|
|
||||||
<TextInput
|
|
||||||
label="Phone number"
|
|
||||||
isLabelHidden
|
|
||||||
value={phone}
|
|
||||||
onChange={setPhone}
|
|
||||||
/>
|
|
||||||
</ExpandableRow>
|
|
||||||
<InfoRowItem
|
|
||||||
label="Identity verification"
|
|
||||||
value="Verified"
|
|
||||||
action=""
|
|
||||||
/>
|
|
||||||
<ExpandableRow
|
|
||||||
label="Residential address"
|
|
||||||
value={address || 'Not provided'}
|
|
||||||
isExpanded={expandedRow === 'address'}
|
|
||||||
onEdit={() => setExpandedRow('address')}
|
|
||||||
onCancel={() => setExpandedRow(null)}
|
|
||||||
onSave={() => setExpandedRow(null)}>
|
|
||||||
<TextInput
|
|
||||||
label="Residential address"
|
|
||||||
isLabelHidden
|
|
||||||
value={address}
|
|
||||||
onChange={setAddress}
|
|
||||||
/>
|
|
||||||
</ExpandableRow>
|
|
||||||
<ExpandableRow
|
|
||||||
label="Mailing address"
|
|
||||||
value={mailingAddress || 'Not provided'}
|
|
||||||
isExpanded={expandedRow === 'mailingAddress'}
|
|
||||||
onEdit={() => setExpandedRow('mailingAddress')}
|
|
||||||
onCancel={() => setExpandedRow(null)}
|
|
||||||
onSave={() => setExpandedRow(null)}>
|
|
||||||
<TextInput
|
|
||||||
label="Mailing address"
|
|
||||||
isLabelHidden
|
|
||||||
value={mailingAddress}
|
|
||||||
onChange={setMailingAddress}
|
|
||||||
/>
|
|
||||||
</ExpandableRow>
|
|
||||||
<ExpandableRow
|
|
||||||
label="Emergency contact"
|
|
||||||
value={emergencyContact}
|
|
||||||
isExpanded={expandedRow === 'emergencyContact'}
|
|
||||||
onEdit={() => setExpandedRow('emergencyContact')}
|
|
||||||
onCancel={() => setExpandedRow(null)}
|
|
||||||
onSave={() => setExpandedRow(null)}>
|
|
||||||
<TextInput
|
|
||||||
label="Emergency contact"
|
|
||||||
isLabelHidden
|
|
||||||
value={emergencyContact}
|
|
||||||
onChange={setEmergencyContact}
|
|
||||||
/>
|
|
||||||
</ExpandableRow>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<Card padding={4}>
|
|
||||||
<VStack gap={4}>
|
|
||||||
<HStack gap={3} vAlign="start">
|
|
||||||
<Center width={48} height={48} style={iconBox}>
|
|
||||||
<Icon icon={LockClosedIcon} />
|
|
||||||
</Center>
|
|
||||||
<VStack gap={0}>
|
|
||||||
<Text type="body" weight="semibold" display="block">
|
|
||||||
Why isn't my info shown here?
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
type="supporting"
|
|
||||||
color="secondary"
|
|
||||||
display="block">
|
|
||||||
We're hiding some account details to protect your
|
|
||||||
identity.
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
<Divider />
|
|
||||||
<HStack gap={3} vAlign="start">
|
|
||||||
<Center width={48} height={48} style={iconBox}>
|
|
||||||
<Icon icon={PencilSquareIcon} />
|
|
||||||
</Center>
|
|
||||||
<VStack gap={0}>
|
|
||||||
<Text type="body" weight="semibold" display="block">
|
|
||||||
Which details can be edited?
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
type="supporting"
|
|
||||||
color="secondary"
|
|
||||||
display="block">
|
|
||||||
Contact info and personal details can be edited. If
|
|
||||||
this info was used to verify your identity,
|
|
||||||
you'll need to get verified again the next time
|
|
||||||
you book—or to continue hosting.
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
<Divider />
|
|
||||||
<HStack gap={3} vAlign="start">
|
|
||||||
<Center width={48} height={48} style={iconBox}>
|
|
||||||
<Icon icon={ShareIcon} />
|
|
||||||
</Center>
|
|
||||||
<VStack gap={0}>
|
|
||||||
<Text type="body" weight="semibold" display="block">
|
|
||||||
What info is shared with others?
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
type="supporting"
|
|
||||||
color="secondary"
|
|
||||||
display="block">
|
|
||||||
We only release contact information after a
|
|
||||||
reservation is confirmed.
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
</Card>
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeNav === 'Privacy' && (
|
|
||||||
<VStack gap={6}>
|
|
||||||
{!isNarrow && <Heading level={2}>Privacy</Heading>}
|
|
||||||
|
|
||||||
<VStack gap={8}>
|
|
||||||
<VStack gap={0}>
|
|
||||||
<Heading level={3}>Messages</Heading>
|
|
||||||
<VStack style={rowPadding}>
|
|
||||||
<Switch
|
|
||||||
label="Show people when I've read their messages."
|
|
||||||
value={readReceipts}
|
|
||||||
onChange={setReadReceipts}
|
|
||||||
labelPosition="start"
|
|
||||||
labelSpacing="spread"
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
<HStack
|
|
||||||
hAlign="between"
|
|
||||||
vAlign="center"
|
|
||||||
style={rowPadding}>
|
|
||||||
<Text type="body" weight="semibold">
|
|
||||||
Blocked people
|
|
||||||
</Text>
|
|
||||||
<Link href="#">View</Link>
|
|
||||||
</HStack>
|
|
||||||
<Divider />
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<VStack gap={0}>
|
|
||||||
<Heading level={3}>Listings</Heading>
|
|
||||||
<VStack style={rowPadding}>
|
|
||||||
<Switch
|
|
||||||
label="Include my listing(s) in search engines"
|
|
||||||
description="Turning this on means search engines, like Google, will display your listing page(s) in search results."
|
|
||||||
value={searchEngines}
|
|
||||||
onChange={setSearchEngines}
|
|
||||||
labelPosition="start"
|
|
||||||
labelSpacing="spread"
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
<Divider />
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<VStack gap={4}>
|
|
||||||
<Heading level={3}>Reviews</Heading>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
Choose what's shared when you write a review.{' '}
|
|
||||||
<Link href="#" type="supporting">
|
|
||||||
Learn more
|
|
||||||
</Link>
|
|
||||||
</Text>
|
|
||||||
<VStack gap={4}>
|
|
||||||
<Switch
|
|
||||||
label="Show my home city and country"
|
|
||||||
description="Ex: City and country"
|
|
||||||
value={showCity}
|
|
||||||
onChange={setShowCity}
|
|
||||||
labelPosition="start"
|
|
||||||
labelSpacing="spread"
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
label="Show my trip type"
|
|
||||||
description="Ex: Stayed with kids or pets"
|
|
||||||
value={showTripType}
|
|
||||||
onChange={setShowTripType}
|
|
||||||
labelPosition="start"
|
|
||||||
labelSpacing="spread"
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
label="Show my length of stay"
|
|
||||||
description="Ex: A few nights, about a week, etc."
|
|
||||||
value={showStayLength}
|
|
||||||
onChange={setShowStayLength}
|
|
||||||
labelPosition="start"
|
|
||||||
labelSpacing="spread"
|
|
||||||
/>
|
|
||||||
<Switch
|
|
||||||
label="Show my booked services"
|
|
||||||
description="Ex: Gourmet brunch or tasting menu"
|
|
||||||
value={showServices}
|
|
||||||
onChange={setShowServices}
|
|
||||||
labelPosition="start"
|
|
||||||
labelSpacing="spread"
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
<Divider />
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<VStack gap={4}>
|
|
||||||
<Heading level={3}>Data privacy</Heading>
|
|
||||||
<Card>
|
|
||||||
<HStack hAlign="between" vAlign="center">
|
|
||||||
<Text type="body">Request my personal data</Text>
|
|
||||||
<Link href="#">Request</Link>
|
|
||||||
</HStack>
|
|
||||||
</Card>
|
|
||||||
<Switch
|
|
||||||
label="Help improve AI-powered features"
|
|
||||||
description="When this is on, we use your data to develop and improve AI models."
|
|
||||||
value={aiFeatures}
|
|
||||||
onChange={setAiFeatures}
|
|
||||||
labelPosition="start"
|
|
||||||
labelSpacing="spread"
|
|
||||||
/>
|
|
||||||
<Card>
|
|
||||||
<HStack hAlign="between" vAlign="center">
|
|
||||||
<Text type="body">Delete my account</Text>
|
|
||||||
<Link href="#">Delete</Link>
|
|
||||||
</HStack>
|
|
||||||
</Card>
|
|
||||||
<Card variant="muted">
|
|
||||||
<HStack gap={4} vAlign="start">
|
|
||||||
<Center
|
|
||||||
width={48}
|
|
||||||
height={48}
|
|
||||||
style={iconBox}>
|
|
||||||
<Icon icon={ShieldCheckIcon} />
|
|
||||||
</Center>
|
|
||||||
<VStack gap={1}>
|
|
||||||
<Text type="body" weight="bold">
|
|
||||||
Committed to privacy
|
|
||||||
</Text>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
We're committed to keeping your data protected.
|
|
||||||
See details in our{' '}
|
|
||||||
<Link href="#" type="supporting">
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
</Card>
|
|
||||||
</VStack>
|
|
||||||
</VStack>
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</LayoutContent>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,441 +0,0 @@
|
|||||||
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import {useState, useMemo} from 'react';
|
|
||||||
import {
|
|
||||||
VStack,
|
|
||||||
HStack,
|
|
||||||
StackItem,
|
|
||||||
Layout,
|
|
||||||
LayoutContent,
|
|
||||||
LayoutHeader,
|
|
||||||
} from '@astryxdesign/core/Layout';
|
|
||||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
|
||||||
import {Button} from '@astryxdesign/core/Button';
|
|
||||||
import {IconButton} from '@astryxdesign/core/IconButton';
|
|
||||||
import {Icon} from '@astryxdesign/core/Icon';
|
|
||||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
|
||||||
import {PowerSearch, usePowerSearchConfig} from '@astryxdesign/core/PowerSearch';
|
|
||||||
import type {PowerSearchFilter} from '@astryxdesign/core/PowerSearch';
|
|
||||||
import {Table, proportional, pixel} from '@astryxdesign/core/Table';
|
|
||||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
|
||||||
import {
|
|
||||||
FunnelIcon,
|
|
||||||
ArrowDownTrayIcon,
|
|
||||||
PlusIcon,
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
|
|
||||||
interface DogRow extends Record<string, unknown> {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
breed: string;
|
|
||||||
biography: string;
|
|
||||||
age: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allDogs: DogRow[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
name: 'Adam',
|
|
||||||
breed: 'Labrador',
|
|
||||||
biography:
|
|
||||||
'I love relaxing in the park and hate skateboards — you might wonder how well those things mix and the answer is "not that well."',
|
|
||||||
age: 17,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
name: 'Al Chihuahua',
|
|
||||||
breed: 'Chihuahua',
|
|
||||||
biography: 'This is a Chihuahua generated by meta.ai',
|
|
||||||
age: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
name: 'Archer',
|
|
||||||
breed: 'Doberman',
|
|
||||||
biography:
|
|
||||||
"I'm an 8 year old Greater Swiss Mountain Dog and I live in Mountain View with my mom and dad.",
|
|
||||||
age: 9,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
name: 'Archie',
|
|
||||||
breed: 'Australian Shepherd',
|
|
||||||
biography: 'Border collie at heart, shepherd by trade.',
|
|
||||||
age: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5',
|
|
||||||
name: 'Argos',
|
|
||||||
breed: 'Labrador',
|
|
||||||
biography: 'Faithful hunting dog who never gives up the trail.',
|
|
||||||
age: 23,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '6',
|
|
||||||
name: 'Banjo',
|
|
||||||
breed: 'Poodle',
|
|
||||||
biography:
|
|
||||||
"Banjo was a rescue from Underdog Rescue in Concord, CA. He's a loving poodle mix that loves to run on the beach.",
|
|
||||||
age: 9,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '7',
|
|
||||||
name: 'Barley',
|
|
||||||
breed: 'Maltese',
|
|
||||||
biography: 'Teacup-sized with a full-sized personality.',
|
|
||||||
age: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '8',
|
|
||||||
name: 'Beast',
|
|
||||||
breed: 'Bulldog',
|
|
||||||
biography: 'Summers in Palo Alto, winters on the couch.',
|
|
||||||
age: 14,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '9',
|
|
||||||
name: 'Belki',
|
|
||||||
breed: 'Pomsky',
|
|
||||||
biography: 'Belki is actually three squirrels in an overcoat.',
|
|
||||||
age: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '10',
|
|
||||||
name: 'Bella',
|
|
||||||
breed: 'Doberman',
|
|
||||||
biography: 'Guard dog by day, cuddle bug by night.',
|
|
||||||
age: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '11',
|
|
||||||
name: 'Brienne',
|
|
||||||
breed: 'Dachshund',
|
|
||||||
biography: 'Brienne of Bark, the cream mini dachshund.',
|
|
||||||
age: 7,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '12',
|
|
||||||
name: 'Bruno',
|
|
||||||
breed: 'Poodle',
|
|
||||||
biography:
|
|
||||||
"Bruno is a shih tzu. He's a smart doggo — I never needed more than a day to teach him a trick.",
|
|
||||||
age: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '13',
|
|
||||||
name: 'Cabo',
|
|
||||||
breed: 'Poodle',
|
|
||||||
biography:
|
|
||||||
'Cabo is a mix of Terrier, Poodle, and unknown. Fat and grumpy but lovable.',
|
|
||||||
age: 9,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '14',
|
|
||||||
name: 'Chai',
|
|
||||||
breed: 'Shiba Inu',
|
|
||||||
biography: 'Sunshine and hiking are the only two things that matter.',
|
|
||||||
age: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '15',
|
|
||||||
name: 'Charlie',
|
|
||||||
breed: 'Golden Retriever',
|
|
||||||
biography: 'The goodest boy in the whole wide world.',
|
|
||||||
age: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '16',
|
|
||||||
name: 'Coco',
|
|
||||||
breed: 'French Bulldog',
|
|
||||||
biography: 'Loves naps and belly rubs more than anything.',
|
|
||||||
age: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '17',
|
|
||||||
name: 'Daisy',
|
|
||||||
breed: 'Beagle',
|
|
||||||
biography: 'Will follow any scent trail anywhere, anytime.',
|
|
||||||
age: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '18',
|
|
||||||
name: 'Duke',
|
|
||||||
breed: 'German Shepherd',
|
|
||||||
biography: 'Guard dog by day, couch potato by night.',
|
|
||||||
age: 11,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '19',
|
|
||||||
name: 'Ella',
|
|
||||||
breed: 'Corgi',
|
|
||||||
biography: 'Short legs, big personality, endless zoomies.',
|
|
||||||
age: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '20',
|
|
||||||
name: 'Finn',
|
|
||||||
breed: 'Husky',
|
|
||||||
biography: 'Loves snow and howling at the moon.',
|
|
||||||
age: 7,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '21',
|
|
||||||
name: 'Ginger',
|
|
||||||
breed: 'Irish Setter',
|
|
||||||
biography: 'Red and rambunctious with boundless energy.',
|
|
||||||
age: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '22',
|
|
||||||
name: 'Hank',
|
|
||||||
breed: 'Basset Hound',
|
|
||||||
biography: 'Ears for days and a nose that never quits.',
|
|
||||||
age: 9,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '23',
|
|
||||||
name: 'Izzy',
|
|
||||||
breed: 'Border Collie',
|
|
||||||
biography: 'Smarter than most people I know.',
|
|
||||||
age: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '24',
|
|
||||||
name: 'Jax',
|
|
||||||
breed: 'Pit Bull',
|
|
||||||
biography: 'Gentle giant who loves kids and belly scratches.',
|
|
||||||
age: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '25',
|
|
||||||
name: 'Koda',
|
|
||||||
breed: 'Akita',
|
|
||||||
biography: 'Loyal, fluffy, and fiercely protective.',
|
|
||||||
age: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '26',
|
|
||||||
name: 'Luna',
|
|
||||||
breed: 'Samoyed',
|
|
||||||
biography: 'A cloud on four legs with a permanent smile.',
|
|
||||||
age: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '27',
|
|
||||||
name: 'Max',
|
|
||||||
breed: 'Rottweiler',
|
|
||||||
biography: 'Looks tough, but melts for ear scratches.',
|
|
||||||
age: 10,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '28',
|
|
||||||
name: 'Nala',
|
|
||||||
breed: 'Labradoodle',
|
|
||||||
biography: 'Hypoallergenic and proud of it.',
|
|
||||||
age: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '29',
|
|
||||||
name: 'Oscar',
|
|
||||||
breed: 'Dachshund',
|
|
||||||
biography: 'Long boy with short legs and big dreams.',
|
|
||||||
age: 12,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '30',
|
|
||||||
name: 'Penny',
|
|
||||||
breed: 'Cavalier King Charles',
|
|
||||||
biography: 'Lap dog extraordinaire and treat connoisseur.',
|
|
||||||
age: 7,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '31',
|
|
||||||
name: 'Rex',
|
|
||||||
breed: 'Doberman',
|
|
||||||
biography: 'Fast, fearless, and first to the food bowl.',
|
|
||||||
age: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '32',
|
|
||||||
name: 'Sadie',
|
|
||||||
breed: 'Australian Shepherd',
|
|
||||||
biography: 'Herds everything, including the cat.',
|
|
||||||
age: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '33',
|
|
||||||
name: 'Tucker',
|
|
||||||
breed: 'Golden Retriever',
|
|
||||||
biography: 'Tennis ball enthusiast and professional fetcher.',
|
|
||||||
age: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '34',
|
|
||||||
name: 'Willow',
|
|
||||||
breed: 'Greyhound',
|
|
||||||
biography: 'Retired racer, full-time lounger.',
|
|
||||||
age: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '35',
|
|
||||||
name: 'Zeus',
|
|
||||||
breed: 'Great Dane',
|
|
||||||
biography: 'Thinks he is a lap dog despite weighing 150 lbs.',
|
|
||||||
age: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '36',
|
|
||||||
name: 'Rosie',
|
|
||||||
breed: 'Maltipoo',
|
|
||||||
biography: 'Tiny but mighty with a bark bigger than her bite.',
|
|
||||||
age: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '37',
|
|
||||||
name: 'Scout',
|
|
||||||
breed: 'Labrador',
|
|
||||||
biography: 'Adventure buddy who never says no to a hike.',
|
|
||||||
age: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '38',
|
|
||||||
name: 'Teddy',
|
|
||||||
breed: 'Bernese Mountain Dog',
|
|
||||||
biography: 'Gentle giant of the mountains.',
|
|
||||||
age: 7,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const breedValues = [
|
|
||||||
{value: 'Labrador', label: 'Labrador'},
|
|
||||||
{value: 'Chihuahua', label: 'Chihuahua'},
|
|
||||||
{value: 'Doberman', label: 'Doberman'},
|
|
||||||
{value: 'Australian Shepherd', label: 'Australian Shepherd'},
|
|
||||||
{value: 'Poodle', label: 'Poodle'},
|
|
||||||
{value: 'Maltese', label: 'Maltese'},
|
|
||||||
{value: 'Bulldog', label: 'Bulldog'},
|
|
||||||
{value: 'Pomsky', label: 'Pomsky'},
|
|
||||||
{value: 'Dachshund', label: 'Dachshund'},
|
|
||||||
{value: 'Shiba Inu', label: 'Shiba Inu'},
|
|
||||||
{value: 'Golden Retriever', label: 'Golden Retriever'},
|
|
||||||
{value: 'French Bulldog', label: 'French Bulldog'},
|
|
||||||
{value: 'Beagle', label: 'Beagle'},
|
|
||||||
{value: 'German Shepherd', label: 'German Shepherd'},
|
|
||||||
{value: 'Corgi', label: 'Corgi'},
|
|
||||||
{value: 'Husky', label: 'Husky'},
|
|
||||||
{value: 'Irish Setter', label: 'Irish Setter'},
|
|
||||||
{value: 'Basset Hound', label: 'Basset Hound'},
|
|
||||||
{value: 'Border Collie', label: 'Border Collie'},
|
|
||||||
{value: 'Pit Bull', label: 'Pit Bull'},
|
|
||||||
{value: 'Akita', label: 'Akita'},
|
|
||||||
{value: 'Samoyed', label: 'Samoyed'},
|
|
||||||
{value: 'Rottweiler', label: 'Rottweiler'},
|
|
||||||
{value: 'Labradoodle', label: 'Labradoodle'},
|
|
||||||
{value: 'Cavalier King Charles', label: 'Cavalier King Charles'},
|
|
||||||
{value: 'Great Dane', label: 'Great Dane'},
|
|
||||||
{value: 'Maltipoo', label: 'Maltipoo'},
|
|
||||||
{value: 'Greyhound', label: 'Greyhound'},
|
|
||||||
{value: 'Bernese Mountain Dog', label: 'Bernese Mountain Dog'},
|
|
||||||
];
|
|
||||||
|
|
||||||
const fieldDefs = [
|
|
||||||
{key: 'name', type: 'string', label: 'Name'},
|
|
||||||
{key: 'breed', type: 'enum', label: 'Breed', enumValues: breedValues},
|
|
||||||
{key: 'biography', type: 'string', label: 'Biography'},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const columns: TableColumn<DogRow>[] = [
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
header: 'Name',
|
|
||||||
width: proportional(2),
|
|
||||||
renderCell: (item: DogRow) => (
|
|
||||||
<HStack gap={3} vAlign="center">
|
|
||||||
<Avatar name={item.name} size="small" />
|
|
||||||
<VStack gap={0}>
|
|
||||||
<Text type="body">{item.name}</Text>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
{item.breed}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'biography',
|
|
||||||
header: 'Biography',
|
|
||||||
width: proportional(5),
|
|
||||||
renderCell: (item: DogRow) => (
|
|
||||||
<Text type="body">{item.biography}</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'age',
|
|
||||||
header: 'Age',
|
|
||||||
width: pixel(80),
|
|
||||||
renderCell: (item: DogRow) => <Text type="body">{item.age}</Text>,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function TablePageTemplate() {
|
|
||||||
const [filters, setFilters] = useState<PowerSearchFilter[]>([]);
|
|
||||||
const {config, applyFilters} = usePowerSearchConfig(fieldDefs, 'Dogs');
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
return applyFilters(filters, allDogs);
|
|
||||||
}, [filters, applyFilters]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout
|
|
||||||
height="auto"
|
|
||||||
header={
|
|
||||||
<LayoutHeader hasDivider>
|
|
||||||
<HStack gap={2} vAlign="center">
|
|
||||||
<StackItem size="fill">
|
|
||||||
<Heading level={1}>Dogs</Heading>
|
|
||||||
</StackItem>
|
|
||||||
<IconButton
|
|
||||||
label="Filter"
|
|
||||||
icon={<Icon icon={FunnelIcon} size="sm" />}
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
label="Download"
|
|
||||||
icon={<Icon icon={ArrowDownTrayIcon} size="sm" />}
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
label="Add"
|
|
||||||
icon={<Icon icon={PlusIcon} size="sm" />}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</LayoutHeader>
|
|
||||||
}
|
|
||||||
content={
|
|
||||||
<LayoutContent padding={3}>
|
|
||||||
<VStack gap={4}>
|
|
||||||
<PowerSearch
|
|
||||||
config={config}
|
|
||||||
filters={filters}
|
|
||||||
onChange={newFilters => {
|
|
||||||
setFilters([...newFilters]);
|
|
||||||
}}
|
|
||||||
placeholder="Search dogs..."
|
|
||||||
resultCount={filtered.length}
|
|
||||||
/>
|
|
||||||
<Table<DogRow>
|
|
||||||
data={filtered}
|
|
||||||
columns={columns}
|
|
||||||
idKey="id"
|
|
||||||
density="balanced"
|
|
||||||
dividers="rows"
|
|
||||||
hasHover
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
</LayoutContent>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,33 @@
|
|||||||
import { EmptyState as AstryxEmptyState } from '@astryxdesign/core/EmptyState';
|
import { Box, Typography } from '@mui/material';
|
||||||
|
import InboxOutlinedIcon from '@mui/icons-material/InboxOutlined';
|
||||||
|
|
||||||
// ==============================|| EMPTY STATE ||============================== //
|
// ==============================|| EMPTY STATE ||============================== //
|
||||||
|
|
||||||
export default function EmptyState({ icon: Icon, title = 'No records found', caption }) {
|
export default function EmptyState({ icon: Icon = InboxOutlinedIcon, title = 'No records found', caption, sx }) {
|
||||||
return (
|
return (
|
||||||
<AstryxEmptyState
|
<Box sx={{ textAlign: 'center', py: 6, px: 2, ...sx }}>
|
||||||
title={title}
|
<Box
|
||||||
description={caption}
|
sx={{
|
||||||
icon={Icon ? <Icon size={36} style={{ color: '#94a3b8' }} /> : undefined}
|
width: 72,
|
||||||
/>
|
height: 72,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: 'grey.100',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
mb: 2
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon sx={{ fontSize: 34, color: 'grey.400' }} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h5" color="text.secondary">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{caption && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||||
|
{caption}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,44 @@
|
|||||||
|
import { Box, Typography } from '@mui/material';
|
||||||
|
|
||||||
// ==============================|| DOORMILE WORDMARK LOGO ||============================== //
|
// ==============================|| DOORMILE WORDMARK LOGO ||============================== //
|
||||||
// Uses the brand wordmark asset (white PNG). `onDark` shows it as-is on dark/red
|
// Uses the brand wordmark asset (white PNG). `onDark` shows it as-is on dark/red
|
||||||
// surfaces; on light surfaces it is recoloured to near-black. `compact` (e.g. the
|
// surfaces; on light surfaces it is recoloured to near-black. `compact` (e.g. the
|
||||||
// navbar) renders the square navbar mark instead, since the wordmark won't fit.
|
// collapsed sidebar) renders just the square "D" badge, since the wordmark won't fit.
|
||||||
|
|
||||||
const LOGO_SRC = '/Doormile-logo.png';
|
const LOGO_SRC = '/Doormile-logo.png';
|
||||||
const NAVBAR_MARK_SRC = '/navbarLogo.png';
|
|
||||||
|
|
||||||
export default function Logo({ onDark = false, compact = false, height = 26, style = {} }) {
|
export default function Logo({ onDark = false, compact = false, height = 26, sx }) {
|
||||||
if (compact) {
|
if (compact) {
|
||||||
|
const mark = onDark ? '#FFFFFF' : '#C01227';
|
||||||
|
const markText = onDark ? '#C01227' : '#FFFFFF';
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', ...style }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', ...sx }}>
|
||||||
<img
|
<Box
|
||||||
src={NAVBAR_MARK_SRC}
|
sx={{
|
||||||
alt="Doormile"
|
width: 34,
|
||||||
style={{
|
height: 34,
|
||||||
width: 64,
|
borderRadius: 2,
|
||||||
height: 64,
|
bgcolor: mark,
|
||||||
display: 'block',
|
display: 'flex',
|
||||||
objectFit: 'cover',
|
alignItems: 'center',
|
||||||
flexShrink: 0
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: onDark ? 'none' : '0 4px 10px rgba(192, 18, 39,0.30)'
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</div>
|
<Typography sx={{ color: markText, fontWeight: 800, fontSize: '1.25rem', lineHeight: 1 }}>D</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', ...style }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', ...sx }}>
|
||||||
<img
|
<Box
|
||||||
|
component="img"
|
||||||
src={LOGO_SRC}
|
src={LOGO_SRC}
|
||||||
alt="Doormile"
|
alt="Doormile"
|
||||||
style={{
|
sx={{
|
||||||
height,
|
height,
|
||||||
width: 'auto',
|
width: 'auto',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
@@ -38,6 +46,6 @@ export default function Logo({ onDark = false, compact = false, height = 26, sty
|
|||||||
filter: onDark ? 'none' : 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)'
|
filter: onDark ? 'none' : 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,42 @@
|
|||||||
// ==============================|| PAGE HEADER (title + actions) ||============================== //
|
import { Box, Typography, Breadcrumbs, Link, Stack } from '@mui/material';
|
||||||
|
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
|
||||||
export default function PageHeader({ title, action }) {
|
// ==============================|| PAGE HEADER (title + breadcrumb + actions) ||============================== //
|
||||||
|
|
||||||
|
export default function PageHeader({ title, breadcrumbs = [], action }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Stack
|
||||||
style={{
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
display: 'flex',
|
justifyContent="space-between"
|
||||||
flexDirection: 'row',
|
alignItems={{ xs: 'flex-start', sm: 'center' }}
|
||||||
justifyContent: 'space-between',
|
spacing={1.5}
|
||||||
alignItems: 'center',
|
sx={{ mb: 3 }}
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: '12px',
|
|
||||||
marginBottom: '24px'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: 0 }}>
|
<Box>
|
||||||
<h1 style={{ margin: 0, fontSize: '1.75rem', fontWeight: 700, color: '#1e293b' }}>
|
<Typography variant="h3" sx={{ fontWeight: 700, color: 'grey.800' }}>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</Typography>
|
||||||
</div>
|
{breadcrumbs.length > 0 && (
|
||||||
{action && <div style={{ flexShrink: 1, minWidth: 0 }}>{action}</div>}
|
<Breadcrumbs separator={<NavigateNextIcon fontSize="small" />} sx={{ mt: 0.5 }}>
|
||||||
</div>
|
<Link component={RouterLink} to="/dashboard" underline="hover" color="text.secondary" variant="caption">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
{breadcrumbs.map((b, i) =>
|
||||||
|
b.to && i < breadcrumbs.length - 1 ? (
|
||||||
|
<Link key={i} component={RouterLink} to={b.to} underline="hover" color="text.secondary" variant="caption">
|
||||||
|
{b.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Typography key={i} variant="caption" color="primary.main" sx={{ fontWeight: 600 }}>
|
||||||
|
{b.label}
|
||||||
|
</Typography>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Breadcrumbs>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{action && <Box sx={{ width: { xs: '100%', sm: 'auto' }, flexShrink: 0 }}>{action}</Box>}
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,99 +1,83 @@
|
|||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card, CardContent, Box, Typography, Avatar, Stack } from '@mui/material';
|
||||||
|
import ArrowUpwardRoundedIcon from '@mui/icons-material/ArrowUpwardRounded';
|
||||||
|
import ArrowDownwardRoundedIcon from '@mui/icons-material/ArrowDownwardRounded';
|
||||||
|
|
||||||
// ==============================|| STAT / KPI CARD ||============================== //
|
// ==============================|| STAT / KPI CARD ||============================== //
|
||||||
|
|
||||||
export default function StatCard({ title, value, icon: Icon, color = 'primary', trend, caption, size = 'md' }) {
|
export default function StatCard({ title, value, icon: Icon, color = 'primary', trend, caption, accent = false }) {
|
||||||
const trendUp = typeof trend === 'number' ? trend >= 0 : null;
|
const trendUp = typeof trend === 'number' ? trend >= 0 : null;
|
||||||
const isCompact = size === 'sm';
|
|
||||||
|
|
||||||
// Map theme colors to CSS values
|
|
||||||
const colorMap = {
|
|
||||||
primary: { main: '#C01227', light: 'rgba(192, 18, 39, 0.04)', border: 'rgba(192, 18, 39, 0.1)' },
|
|
||||||
secondary: { main: '#475569', light: 'rgba(71, 85, 105, 0.04)', border: 'rgba(71, 85, 105, 0.1)' },
|
|
||||||
success: { main: '#10b981', light: 'rgba(16, 185, 129, 0.04)', border: 'rgba(16, 185, 129, 0.1)' },
|
|
||||||
warning: { main: '#f59e0b', light: 'rgba(245, 158, 11, 0.04)', border: 'rgba(245, 158, 11, 0.1)' },
|
|
||||||
error: { main: '#ef4444', light: 'rgba(239, 68, 68, 0.04)', border: 'rgba(239, 68, 68, 0.1)' },
|
|
||||||
info: { main: '#3b82f6', light: 'rgba(59, 130, 246, 0.04)', border: 'rgba(59, 130, 246, 0.1)' }
|
|
||||||
};
|
|
||||||
|
|
||||||
const themeColor = colorMap[color] || colorMap.primary;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
style={{
|
sx={{
|
||||||
height: '100%',
|
height: '100%',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
borderRadius: isCompact ? '12px' : '16px',
|
borderRadius: 3,
|
||||||
padding: isCompact ? '16px' : '24px',
|
boxShadow: '0 8px 24px rgba(0,0,0,0.03)',
|
||||||
border: `1px solid ${themeColor.border}`,
|
border: '1px solid rgba(0,0,0,0.04)',
|
||||||
boxShadow: '0 4px 20px rgba(0,0,0,0.02)',
|
|
||||||
background: `linear-gradient(135deg, #ffffff 0%, ${themeColor.light} 100%)`,
|
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
cursor: 'pointer',
|
'&:hover': { transform: 'translateY(-4px)', boxShadow: '0 16px 32px rgba(0,0,0,0.06)' },
|
||||||
boxSizing: 'border-box'
|
background: (theme) => `linear-gradient(145deg, ${theme.palette.background.paper} 0%, ${theme.palette[color].lighter}15 100%)`
|
||||||
}}
|
}}
|
||||||
className="stat-card"
|
|
||||||
>
|
>
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<div style={{ position: 'absolute', right: '-12px', bottom: '-12px', opacity: 0.04, transform: 'rotate(-15deg)', pointerEvents: 'none', color: themeColor.main }}>
|
<Box sx={{ position: 'absolute', right: -15, bottom: -15, opacity: 0.04, transform: 'rotate(-15deg)', pointerEvents: 'none' }}>
|
||||||
<Icon size={isCompact ? 64 : 96} />
|
<Icon sx={{ fontSize: 110 }} />
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 }, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: isCompact ? '10px' : '16px', position: 'relative', zIndex: 2 }}>
|
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1.5, position: 'relative', zIndex: 2 }}>
|
||||||
<span style={{ fontWeight: 700, fontSize: '0.75rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</Typography>
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
width: isCompact ? '28px' : '36px',
|
width: 38,
|
||||||
height: isCompact ? '28px' : '36px',
|
height: 38,
|
||||||
borderRadius: '8px',
|
borderRadius: 2,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: 'rgba(192, 18, 39, 0.08)',
|
bgcolor: `${color}.lighter`,
|
||||||
color: '#C01227'
|
color: `${color}.main`,
|
||||||
|
boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.05)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon size={isCompact ? 14 : 18} />
|
<Icon fontSize="small" />
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Stack>
|
||||||
|
|
||||||
<div style={{ flexGrow: 1, position: 'relative', zIndex: 2, marginBottom: (trendUp !== null || caption) ? (isCompact ? '10px' : '16px') : '0' }}>
|
<Box sx={{ flexGrow: 1, position: 'relative', zIndex: 2 }}>
|
||||||
<span style={{ fontWeight: 800, fontSize: isCompact ? '1.5rem' : '2rem', color: '#1e293b', lineHeight: 1, display: 'block' }}>
|
<Typography variant="h4" sx={{ fontWeight: 800, color: 'grey.900', letterSpacing: '-0.5px', lineHeight: 1 }}>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
{(trendUp !== null || caption) && (
|
{(trendUp !== null || caption) && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '8px', paddingTop: isCompact ? '8px' : '12px', borderTop: '1px dashed #e2e8f0', position: 'relative', zIndex: 2 }}>
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 2, pt: 1.5, borderTop: '1px dashed rgba(0,0,0,0.06)', position: 'relative', zIndex: 2 }}>
|
||||||
{trendUp !== null && (
|
{trendUp !== null && (
|
||||||
<div
|
<Stack direction="row" spacing={0.25} alignItems="center" sx={{ bgcolor: trendUp ? 'success.lighter' : 'error.lighter', px: 0.75, py: 0.25, borderRadius: 1 }}>
|
||||||
style={{
|
{trendUp ? (
|
||||||
display: 'flex',
|
<ArrowUpwardRoundedIcon sx={{ fontSize: 14, color: 'success.dark' }} />
|
||||||
alignItems: 'center',
|
) : (
|
||||||
gap: '2px',
|
<ArrowDownwardRoundedIcon sx={{ fontSize: 14, color: 'error.dark' }} />
|
||||||
backgroundColor: trendUp ? '#E3F6EC' : '#FEEAE9',
|
)}
|
||||||
padding: '2px 6px',
|
<Typography variant="caption" sx={{ fontWeight: 700, color: trendUp ? 'success.dark' : 'error.dark' }}>
|
||||||
borderRadius: '4px'
|
{Math.abs(trend)}%
|
||||||
}}
|
</Typography>
|
||||||
>
|
</Stack>
|
||||||
<span style={{ fontSize: '11px', fontWeight: 700, color: trendUp ? '#00773B' : '#A82216' }}>
|
|
||||||
{trendUp ? '↑' : '↓'} {Math.abs(trend)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{caption && (
|
{caption && (
|
||||||
<span style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 600 }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>
|
||||||
{caption}
|
{caption}
|
||||||
</span>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,65 @@
|
|||||||
import { Badge } from '@astryxdesign/core/Badge';
|
import { Chip } from '@mui/material';
|
||||||
|
|
||||||
// ==============================|| STATUS CHIP ||============================== //
|
// ==============================|| STATUS CHIP ||============================== //
|
||||||
// Soft-filled status chips used across orders, deliveries, riders, invoices.
|
// Soft-filled status chips used across orders, deliveries, riders, invoices.
|
||||||
|
|
||||||
const MAP = {
|
const MAP = {
|
||||||
// orders / deliveries
|
// orders / deliveries
|
||||||
pending: { variant: 'warning', label: 'Pending' },
|
pending: { color: 'warning', label: 'Pending' },
|
||||||
created: { variant: 'info', label: 'Created' },
|
created: { color: 'info', label: 'Created' },
|
||||||
assigned: { variant: 'info', label: 'Assigned' },
|
assigned: { color: 'info', label: 'Assigned' },
|
||||||
accepted: { variant: 'info', label: 'Accepted' },
|
accepted: { color: 'info', label: 'Accepted' },
|
||||||
arrived: { variant: 'info', label: 'Arrived' },
|
arrived: { color: 'info', label: 'Arrived' },
|
||||||
picked: { variant: 'red', label: 'Picked' },
|
picked: { color: 'primary', label: 'Picked' },
|
||||||
'in-transit': { variant: 'info', label: 'In Transit' },
|
'in-transit': { color: 'info', label: 'In Transit' },
|
||||||
active: { variant: 'red', label: 'Active' },
|
active: { color: 'primary', label: 'Active' },
|
||||||
delivered: { variant: 'success', label: 'Delivered' },
|
delivered: { color: 'success', label: 'Delivered' },
|
||||||
completed: { variant: 'success', label: 'Completed' },
|
completed: { color: 'success', label: 'Completed' },
|
||||||
skipped: { variant: 'warning', label: 'Skipped' },
|
skipped: { color: 'warning', label: 'Skipped' },
|
||||||
failed: { variant: 'error', label: 'Failed' },
|
failed: { color: 'error', label: 'Failed' },
|
||||||
cancelled: { variant: 'error', label: 'Cancelled' },
|
cancelled: { color: 'error', label: 'Cancelled' },
|
||||||
'miler-assigned': { variant: 'info', label: 'Miler Assigned' },
|
|
||||||
'pending-pickup': { variant: 'warning', label: 'Pending Pickup' },
|
|
||||||
// clients (doormile_clients)
|
// clients (doormile_clients)
|
||||||
newclient: { variant: 'info', label: 'New Client' },
|
newclient: { color: 'info', label: 'New Client' },
|
||||||
contacted: { variant: 'warning', label: 'Contacted' },
|
contacted: { color: 'warning', label: 'Contacted' },
|
||||||
onboarded: { variant: 'success', label: 'Onboarded' },
|
onboarded: { color: 'success', label: 'Onboarded' },
|
||||||
lost: { variant: 'error', label: 'Lost' },
|
lost: { color: 'error', label: 'Lost' },
|
||||||
// team users (doormile_auth)
|
// team users (doormile_auth)
|
||||||
admin: { variant: 'red', label: 'Admin' },
|
admin: { color: 'primary', label: 'Admin' },
|
||||||
rep: { variant: 'info', label: 'Rep' },
|
rep: { color: 'info', label: 'Rep' },
|
||||||
manager: { variant: 'success', label: 'Manager' },
|
manager: { color: 'success', label: 'Manager' },
|
||||||
support: { variant: 'warning', label: 'Support' },
|
support: { color: 'warning', label: 'Support' },
|
||||||
// riders / tenants
|
// riders / tenants
|
||||||
online: { variant: 'success', label: 'Online' },
|
online: { color: 'success', label: 'Online' },
|
||||||
offline: { variant: 'neutral', label: 'Offline' },
|
offline: { color: 'default', label: 'Offline' },
|
||||||
'on-delivery': { variant: 'info', label: 'On Delivery' },
|
'on-delivery': { color: 'info', label: 'On Delivery' },
|
||||||
inactive: { variant: 'neutral', label: 'Inactive' },
|
inactive: { color: 'default', label: 'Inactive' },
|
||||||
// invoices
|
// invoices
|
||||||
paid: { variant: 'success', label: 'Paid' },
|
paid: { color: 'success', label: 'Paid' },
|
||||||
open: { variant: 'info', label: 'Open' },
|
open: { color: 'info', label: 'Open' },
|
||||||
overdue: { variant: 'error', label: 'Overdue' },
|
overdue: { color: 'error', label: 'Overdue' },
|
||||||
prepaid: { variant: 'success', label: 'Prepaid' },
|
prepaid: { color: 'success', label: 'Prepaid' },
|
||||||
cod: { variant: 'warning', label: 'COD' }
|
cod: { color: 'warning', label: 'COD' }
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function StatusChip({ status, label }) {
|
const TONE = {
|
||||||
|
success: { bg: '#E3F6EC', fg: '#00773B' },
|
||||||
|
warning: { bg: '#FFF7E0', fg: '#8A6500' },
|
||||||
|
info: { bg: '#E0F7F8', fg: '#00727B' },
|
||||||
|
error: { bg: '#FEEAE9', fg: '#A82216' },
|
||||||
|
primary: { bg: '#F8E0E3', fg: '#9E0E20' },
|
||||||
|
default: { bg: '#F0F0F0', fg: '#595959' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatusChip({ status, size = 'small', label, sx }) {
|
||||||
const key = String(status || '').toLowerCase().replace(/\s+/g, '-');
|
const key = String(status || '').toLowerCase().replace(/\s+/g, '-');
|
||||||
const humanized = String(status || '').replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
const humanized = String(status || '').replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
const cfg = MAP[key] || { variant: 'neutral', label: humanized || status };
|
const cfg = MAP[key] || { color: 'default', label: humanized || status };
|
||||||
|
const tone = TONE[cfg.color] || TONE.default;
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Chip
|
||||||
variant={cfg.variant}
|
size={size}
|
||||||
label={label || cfg.label}
|
label={label || cfg.label}
|
||||||
|
sx={{ bgcolor: tone.bg, color: tone.fg, border: 'none', ...sx }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Badge } from '@astryxdesign/core/Badge';
|
import { Stack, Box } from '@mui/material';
|
||||||
|
|
||||||
// ==============================|| TAB LABEL WITH INLINE COUNT PILL ||============================== //
|
// ==============================|| TAB LABEL WITH INLINE COUNT PILL ||============================== //
|
||||||
// Renders a tab label with the count laid out inline (not an overlapping Badge),
|
// Renders a tab label with the count laid out inline (not an overlapping Badge),
|
||||||
@@ -6,9 +6,25 @@ import { Badge } from '@astryxdesign/core/Badge';
|
|||||||
|
|
||||||
export default function TabLabelCount({ label, count, active = false }) {
|
export default function TabLabelCount({ label, count, active = false }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '8px' }}>
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<Badge variant={active ? 'red' : 'neutral'} label={count} />
|
<Box
|
||||||
</div>
|
component="span"
|
||||||
|
sx={{
|
||||||
|
minWidth: 20,
|
||||||
|
height: 20,
|
||||||
|
px: 0.75,
|
||||||
|
borderRadius: 10,
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: '20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
bgcolor: active ? 'primary.main' : 'grey.200',
|
||||||
|
color: active ? '#fff' : 'text.secondary'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Avatar } from '@astryxdesign/core/Avatar';
|
import { Avatar } from '@mui/material';
|
||||||
|
import { stringToColor, initials } from '@/utils/format';
|
||||||
|
|
||||||
// ==============================|| INITIALS AVATAR ||============================== //
|
// ==============================|| INITIALS AVATAR ||============================== //
|
||||||
|
|
||||||
export default function UserAvatar({ name = '', size = 32 }) {
|
export default function UserAvatar({ name = '', size = 32, sx }) {
|
||||||
return (
|
return (
|
||||||
<Avatar name={name} size={size} />
|
<Avatar sx={{ width: size, height: size, bgcolor: stringToColor(name), fontSize: size * 0.42, ...sx }}>
|
||||||
|
{initials(name)}
|
||||||
|
</Avatar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Box, Stack, Typography } from '@mui/material';
|
||||||
|
|
||||||
// Dependency-free donut chart. data: [{ label, value, color }]
|
// Dependency-free donut chart. data: [{ label, value, color }]
|
||||||
export default function DonutChart({ data = [], size = 180, thickness = 26, centerLabel, centerValue }) {
|
export default function DonutChart({ data = [], size = 180, thickness = 26, centerLabel, centerValue }) {
|
||||||
const total = data.reduce((s, d) => s + d.value, 0) || 1;
|
const total = data.reduce((s, d) => s + d.value, 0) || 1;
|
||||||
@@ -6,18 +8,14 @@ export default function DonutChart({ data = [], size = 180, thickness = 26, cent
|
|||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Stack
|
||||||
style={{
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
display: 'flex',
|
spacing={3}
|
||||||
flexDirection: 'row',
|
alignItems="center"
|
||||||
gap: '24px',
|
justifyContent="center"
|
||||||
alignItems: 'center',
|
sx={{ flexWrap: 'wrap' }}
|
||||||
justifyContent: 'center',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
fontFamily: 'system-ui, sans-serif'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div style={{ position: 'relative', width: `${size}px`, maxWidth: '100%', aspectRatio: '1 / 1' }}>
|
<Box sx={{ position: 'relative', width: size, maxWidth: '100%', aspectRatio: '1 / 1' }}>
|
||||||
<svg width="100%" height="100%" viewBox={`0 0 ${size} ${size}`}>
|
<svg width="100%" height="100%" viewBox={`0 0 ${size} ${size}`}>
|
||||||
<g transform={`rotate(-90 ${size / 2} ${size / 2})`}>
|
<g transform={`rotate(-90 ${size / 2} ${size / 2})`}>
|
||||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="#F0F0F0" strokeWidth={thickness} />
|
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="#F0F0F0" strokeWidth={thickness} />
|
||||||
@@ -42,20 +40,20 @@ export default function DonutChart({ data = [], size = 180, thickness = 26, cent
|
|||||||
})}
|
})}
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<div style={{ fontSize: '2rem', fontWeight: 700, color: '#1e293b' }}>{centerValue ?? total}</div>
|
<Typography variant="h3" sx={{ fontWeight: 700 }}>{centerValue ?? total}</Typography>
|
||||||
<div style={{ fontSize: '0.75rem', color: '#64748b' }}>{centerLabel ?? 'Total'}</div>
|
<Typography variant="caption" color="text.secondary">{centerLabel ?? 'Total'}</Typography>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
<Stack spacing={1.25}>
|
||||||
{data.map((d) => (
|
{data.map((d) => (
|
||||||
<div key={d.label} style={{ display: 'flex', flexDirection: 'row', gap: '8px', alignItems: 'center' }}>
|
<Stack key={d.label} direction="row" spacing={1} alignItems="center">
|
||||||
<div style={{ width: '10px', height: '10px', borderRadius: '3px', backgroundColor: d.color }} />
|
<Box sx={{ width: 10, height: 10, borderRadius: '3px', bgcolor: d.color }} />
|
||||||
<div style={{ fontSize: '0.875rem', color: '#64748b', minWidth: '80px' }}>{d.label}</div>
|
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 80 }}>{d.label}</Typography>
|
||||||
<div style={{ fontSize: '0.875rem', fontWeight: 600, color: '#1e293b' }}>{d.value.toLocaleString('en-IN')}</div>
|
<Typography variant="subtitle2">{d.value.toLocaleString('en-IN')}</Typography>
|
||||||
</div>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Stack>
|
||||||
</div>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
177
src/index.css
177
src/index.css
@@ -1,177 +0,0 @@
|
|||||||
/* ─── Force light mode + brand color: override every Astryx light-dark() token ─── */
|
|
||||||
/* Astryx's <Theme> renders a wrapper div carrying [data-astryx-theme] in addition to
|
|
||||||
syncing the attribute onto <html>; theme.css sets tokens directly on that wrapper via
|
|
||||||
@scope, so a direct-on-element declaration beats one merely inherited from :root
|
|
||||||
regardless of layers. Targeting [data-astryx-theme] here (not just :root) ensures our
|
|
||||||
overrides win on the actual element the tokens are read from. */
|
|
||||||
:root,
|
|
||||||
[data-astryx-theme] {
|
|
||||||
color-scheme: light;
|
|
||||||
|
|
||||||
/* Surface / background tokens */
|
|
||||||
--color-background-surface: #ffffff;
|
|
||||||
--color-background-body: #f1f4f7;
|
|
||||||
--color-background-card: #ffffff;
|
|
||||||
--color-background-popover: #ffffff;
|
|
||||||
--color-background-muted: rgba(5, 54, 89, 0.047);
|
|
||||||
--color-background-inverted: #0A1317;
|
|
||||||
--color-background-error-inverted: #AA071E;
|
|
||||||
|
|
||||||
/* Text tokens */
|
|
||||||
--color-text-primary: #0A1317;
|
|
||||||
--color-text-secondary: #4E606F;
|
|
||||||
--color-text-disabled: #A4B0BC;
|
|
||||||
--color-text-accent: #C01227;
|
|
||||||
|
|
||||||
/* Icon tokens */
|
|
||||||
--color-icon-primary: #0A1317;
|
|
||||||
--color-icon-secondary: #4E606F;
|
|
||||||
--color-icon-disabled: #A4B0BC;
|
|
||||||
--color-icon-accent: #C01227;
|
|
||||||
|
|
||||||
/* Border tokens */
|
|
||||||
--color-border: rgba(5, 54, 89, 0.1);
|
|
||||||
--color-border-emphasized: #CCD3DB;
|
|
||||||
|
|
||||||
/* Interactive overlay tokens */
|
|
||||||
--color-neutral: rgba(5, 54, 89, 0.1);
|
|
||||||
--color-overlay: rgba(1, 18, 40, 0.4);
|
|
||||||
--color-overlay-hover: rgba(5, 54, 89, 0.047);
|
|
||||||
--color-overlay-pressed: rgba(5, 54, 89, 0.098);
|
|
||||||
|
|
||||||
/* Misc */
|
|
||||||
--color-skeleton: #CCD3DB;
|
|
||||||
--color-track: #CCD3DB;
|
|
||||||
--color-shadow: rgba(5, 54, 89, 0.1);
|
|
||||||
|
|
||||||
/* Brand accent — Doormile red, replaces the theme-neutral default */
|
|
||||||
--color-accent: #C01227;
|
|
||||||
--color-accent-muted: rgba(192, 18, 39, 0.12);
|
|
||||||
--color-on-accent: #ffffff;
|
|
||||||
|
|
||||||
/* App-level overrides */
|
|
||||||
background-color: #f8fafc;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Apply professional font styling globally */
|
|
||||||
*, *::before, *::after {
|
|
||||||
font-family: 'Public Sans', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: #f8fafc;
|
|
||||||
color: #334155; /* slate-700 for highly readable, professional body text */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header typography styling for a crisp, modern look */
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
font-weight: 700;
|
|
||||||
color: #0f172a; /* slate-900 for bold headers */
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* All native inputs and textareas — catches every Astryx TextInput */
|
|
||||||
input, textarea {
|
|
||||||
font-family: inherit !important;
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
color: #0f172a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
input::placeholder, textarea::placeholder {
|
|
||||||
color: #94a3b8 !important;
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Base input overrides for custom plain inputs (excluding Astryx styled components) */
|
|
||||||
input:not(.search-bar-input):not([class]), select:not([class]), textarea:not([class]) {
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
color: #0f172a !important;
|
|
||||||
border: 1px solid rgba(5, 54, 89, 0.12) !important;
|
|
||||||
border-radius: 8px !important;
|
|
||||||
padding: 10px 14px !important;
|
|
||||||
font-size: 0.875rem !important;
|
|
||||||
outline: none !important;
|
|
||||||
transition: border-color 0.2s, box-shadow 0.2s !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:not(.search-bar-input):not([class]), select:not([class]) {
|
|
||||||
height: 38px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:not(.search-bar-input):not([class]):focus, select:not([class]):focus, textarea:not([class]):focus {
|
|
||||||
border-color: #C01227 !important;
|
|
||||||
box-shadow: 0 0 0 3px rgba(192, 18, 39, 0.12) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Premium Select Dropdown Styling */
|
|
||||||
select:not([class]) {
|
|
||||||
appearance: none !important;
|
|
||||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%20fill='none'%20stroke='%2364748b'%20stroke-width='2'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cpolyline%20points='6%209%2012%2015%2018%209'%3E%3C/polyline%3E%3C/svg%3E") !important;
|
|
||||||
background-repeat: no-repeat !important;
|
|
||||||
background-position: right 12px center !important;
|
|
||||||
background-size: 16px !important;
|
|
||||||
padding-right: 40px !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Premium Search Bar Custom Styling */
|
|
||||||
.search-bar-form {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
border: 1px solid #cbd5e1;
|
|
||||||
border-radius: 9999px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 320px;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar-form:focus-within {
|
|
||||||
border-color: #C01227;
|
|
||||||
box-shadow: 0 0 0 4px rgba(192, 18, 39, 0.12), 0 10px 15px -3px rgba(0, 0, 0, 0.05);
|
|
||||||
max-width: 380px; /* Expand slightly on focus for premium desktop feel */
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-bar-input {
|
|
||||||
border: none !important;
|
|
||||||
background: transparent !important;
|
|
||||||
outline: none !important;
|
|
||||||
font-size: 0.875rem !important;
|
|
||||||
color: #0f172a !important;
|
|
||||||
width: 100% !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table and interactive enhancements */
|
|
||||||
.table-row-hover:hover {
|
|
||||||
background-color: #f8fafc !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pagination rows per page dropdown */
|
|
||||||
.rpp-select {
|
|
||||||
appearance: none !important;
|
|
||||||
-webkit-appearance: none !important;
|
|
||||||
width: 75px !important;
|
|
||||||
height: 32px !important;
|
|
||||||
padding: 4px 24px 4px 8px !important;
|
|
||||||
border-radius: 6px !important;
|
|
||||||
border: 1px solid rgba(5, 54, 89, 0.12) !important;
|
|
||||||
background-color: #ffffff !important;
|
|
||||||
color: #0f172a !important;
|
|
||||||
font-size: 0.875rem !important;
|
|
||||||
outline: none !important;
|
|
||||||
cursor: pointer !important;
|
|
||||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='24'%20height='24'%20viewBox='0%200%2024%2024'%20fill='none'%20stroke='%2364748b'%20stroke-width='2'%20stroke-linecap='round'%20stroke-linejoin='round'%3E%3Cpolyline%20points='6%209%2012%2015%2018%209'%3E%3C/polyline%3E%3C/svg%3E") !important;
|
|
||||||
background-repeat: no-repeat !important;
|
|
||||||
background-position: right 8px center !important;
|
|
||||||
background-size: 12px !important;
|
|
||||||
}
|
|
||||||
@@ -1,79 +1,242 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Settings, LogOut } from 'lucide-react';
|
import {
|
||||||
import { TopNav, TopNavHeading } from '@astryxdesign/core/TopNav';
|
AppBar,
|
||||||
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
|
Toolbar,
|
||||||
|
IconButton,
|
||||||
|
Box,
|
||||||
|
InputBase,
|
||||||
|
Avatar,
|
||||||
|
Typography,
|
||||||
|
Stack,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Divider,
|
||||||
|
ListItemIcon,
|
||||||
|
Popper,
|
||||||
|
Paper,
|
||||||
|
ClickAwayListener,
|
||||||
|
CircularProgress,
|
||||||
|
alpha
|
||||||
|
} from '@mui/material';
|
||||||
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
||||||
|
import LogoutIcon from '@mui/icons-material/Logout';
|
||||||
|
|
||||||
import Logo from '@/components/Logo';
|
import Logo from '@/components/Logo';
|
||||||
import UserAvatar from '@/components/UserAvatar';
|
import UserAvatar from '@/components/UserAvatar';
|
||||||
import { fetchUsers } from '@/utils/apiClient';
|
import { fetchClients, fetchUsers } from '@/utils/apiClient';
|
||||||
|
import { toClient } from '@/utils/mappers';
|
||||||
|
|
||||||
export default function Header() {
|
const RED = '#C01227';
|
||||||
|
|
||||||
|
export default function Header({ onToggle }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [account, setAccount] = useState(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
// Read user from localStorage
|
||||||
let storedUserObj = { name: 'Admin', role: 'Operations Admin', id: 0 };
|
let storedUserObj = { name: 'Admin', role: 'Operations Admin', id: 0 };
|
||||||
try {
|
try {
|
||||||
const storedUser = localStorage.getItem('user');
|
const storedUser = localStorage.getItem('user');
|
||||||
if (storedUser) storedUserObj = JSON.parse(storedUser);
|
if (storedUser) {
|
||||||
|
storedUserObj = JSON.parse(storedUser);
|
||||||
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
|
||||||
const [activeUserName, setActiveUserName] = useState(storedUserObj.name || 'Admin');
|
const [activeUserName, setActiveUserName] = useState(storedUserObj.name || 'Admin');
|
||||||
|
|
||||||
const displayRole = storedUserObj.role === 'admin' ? 'Administrator' :
|
const displayRole = storedUserObj.role === 'admin' ? 'Administrator' :
|
||||||
storedUserObj.role === 'manager' ? 'Manager' :
|
storedUserObj.role === 'manager' ? 'Manager' :
|
||||||
storedUserObj.role === 'executive' ? 'Executive' : 'Operations Admin';
|
storedUserObj.role === 'executive' ? 'Executive' : 'Operations Admin';
|
||||||
|
const displayInitial = activeUserName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
const searchRef = useRef(null);
|
||||||
|
const [clients, setClients] = useState([]);
|
||||||
|
const [loadedClients, setLoadedClients] = useState(false);
|
||||||
|
const [loadingClients, setLoadingClients] = useState(false);
|
||||||
|
const [openResults, setOpenResults] = useState(false);
|
||||||
|
|
||||||
|
// Fetch users to get the real name if the login payload didn't have it
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers().then((users) => {
|
fetchUsers().then((users) => {
|
||||||
if (storedUserObj.email) {
|
if (storedUserObj.email) {
|
||||||
|
// Strictly use email to match, as stored ID might belong to the doormile_auth table instead of appusers table
|
||||||
const matchingUser = users.find(u => u.email === storedUserObj.email);
|
const matchingUser = users.find(u => u.email === storedUserObj.email);
|
||||||
if (matchingUser && matchingUser.first_name) {
|
if (matchingUser && matchingUser.first_name) {
|
||||||
setActiveUserName(matchingUser.first_name);
|
setActiveUserName(matchingUser.first_name);
|
||||||
localStorage.setItem('user', JSON.stringify({ ...storedUserObj, name: matchingUser.first_name }));
|
// Also update localStorage so it's fresh
|
||||||
|
const updatedUser = { ...storedUserObj, name: matchingUser.first_name };
|
||||||
|
localStorage.setItem('user', JSON.stringify(updatedUser));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const ensureClients = () => {
|
||||||
|
if (loadedClients || loadingClients) return;
|
||||||
|
setLoadingClients(true);
|
||||||
|
fetchClients()
|
||||||
|
.then((points) => setClients(points.map(toClient)))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => { setLoadedClients(true); setLoadingClients(false); });
|
||||||
|
};
|
||||||
|
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
const results = q
|
||||||
|
? clients.filter((c) => [c.name, c.city, c.businessType, c.phone].join(' ').toLowerCase().includes(q)).slice(0, 6)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const onSearchChange = (e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
ensureClients();
|
||||||
|
setOpenResults(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToClients = (term) => {
|
||||||
|
navigate(`/tenants?q=${encodeURIComponent(term)}`);
|
||||||
|
setSearch('');
|
||||||
|
setOpenResults(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitSearch = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const term = search.trim();
|
||||||
|
if (term) goToClients(term);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AppBar
|
||||||
|
position="fixed"
|
||||||
|
elevation={0}
|
||||||
|
sx={{ bgcolor: '#fff', color: 'grey.800', zIndex: (t) => t.zIndex.drawer + 1, borderBottom: 1, borderColor: 'divider' }}
|
||||||
|
>
|
||||||
|
<Toolbar sx={{ minHeight: 64, px: { xs: 1.5, sm: 2.5 }, gap: 1 }}>
|
||||||
|
<IconButton color="inherit" edge="start" onClick={onToggle} sx={{ mr: 0.5 }}>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<Logo height={22} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
|
||||||
|
<ClickAwayListener onClickAway={() => setOpenResults(false)}>
|
||||||
|
<Box sx={{ display: { xs: 'none', sm: 'block' }, position: 'relative' }}>
|
||||||
|
<Box
|
||||||
|
ref={searchRef}
|
||||||
|
component="form"
|
||||||
|
onSubmit={submitSearch}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
bgcolor: 'grey.100',
|
||||||
|
borderRadius: 2,
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.5,
|
||||||
|
width: { sm: 240, md: 320 },
|
||||||
|
'&:hover': { bgcolor: 'grey.200' },
|
||||||
|
'&:focus-within': { bgcolor: 'grey.200' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SearchIcon sx={{ fontSize: 20, mr: 1, color: 'grey.500' }} />
|
||||||
|
<InputBase
|
||||||
|
value={search}
|
||||||
|
onChange={onSearchChange}
|
||||||
|
onFocus={() => { ensureClients(); if (search.trim()) setOpenResults(true); }}
|
||||||
|
placeholder="Search clients…"
|
||||||
|
sx={{ color: 'grey.800', fontSize: '0.875rem', flex: 1, '&::placeholder': { color: 'grey.500' } }}
|
||||||
|
inputProps={{ 'aria-label': 'search' }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Popper
|
||||||
|
open={openResults && !!q}
|
||||||
|
anchorEl={searchRef.current}
|
||||||
|
placement="bottom-start"
|
||||||
|
style={{ zIndex: 1400, width: searchRef.current?.offsetWidth }}
|
||||||
|
>
|
||||||
|
<Paper sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', boxShadow: '0 8px 24px rgba(0,0,0,0.18)' }}>
|
||||||
|
{loadingClients && results.length === 0 ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2.5 }}><CircularProgress size={20} /></Box>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<Box sx={{ px: 2, py: 2 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">No clients match “{search.trim()}”.</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<TopNav
|
{results.map((c) => (
|
||||||
label="Main navigation"
|
<MenuItem key={c.id} onClick={() => goToClients(c.name)} sx={{ py: 1, gap: 1.25 }}>
|
||||||
style={{
|
<UserAvatar name={c.name} size={30} />
|
||||||
backgroundColor: '#ffffff',
|
<Box sx={{ minWidth: 0 }}>
|
||||||
borderBottom: '1px solid rgba(5, 54, 89, 0.08)',
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }} noWrap>{c.name}</Typography>
|
||||||
boxShadow: '0 1px 3px rgba(15, 23, 42, 0.04)'
|
<Typography variant="caption" color="text.secondary" noWrap>
|
||||||
}}
|
{[c.businessType, c.city].filter(Boolean).join(' · ') || c.phone}
|
||||||
heading={
|
</Typography>
|
||||||
<TopNavHeading
|
</Box>
|
||||||
logo={<Logo compact height={28} />}
|
</MenuItem>
|
||||||
heading="Doormile CRM"
|
))}
|
||||||
subheading={displayRole}
|
<Divider />
|
||||||
headingHref="/dashboard"
|
<MenuItem onClick={() => goToClients(search.trim())} sx={{ py: 1.25, color: 'primary.main', fontWeight: 600 }}>
|
||||||
/>
|
<SearchIcon fontSize="small" sx={{ mr: 1 }} />
|
||||||
}
|
See all results for “{search.trim()}”
|
||||||
endContent={
|
</MenuItem>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
|
||||||
<DropdownMenu
|
|
||||||
button={{
|
|
||||||
variant: 'ghost',
|
|
||||||
size: 'lg',
|
|
||||||
icon: <UserAvatar name={activeUserName} size={22} />,
|
|
||||||
label: activeUserName
|
|
||||||
}}
|
|
||||||
items={[
|
|
||||||
{ label: 'Settings', icon: Settings, onClick: () => navigate('/settings') },
|
|
||||||
{ type: 'divider' },
|
|
||||||
{
|
|
||||||
label: 'Logout',
|
|
||||||
icon: LogOut,
|
|
||||||
onClick: () => { localStorage.removeItem('auth_token'); navigate('/login'); }
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Popper>
|
||||||
|
</Box>
|
||||||
|
</ClickAwayListener>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
onClick={(e) => setAccount(e.currentTarget)}
|
||||||
|
sx={{ display: 'flex', alignItems: 'center', gap: 1, ml: 0.5, cursor: 'pointer', py: 0.5, px: 0.5, borderRadius: 2, '&:hover': { bgcolor: 'grey.100' } }}
|
||||||
|
>
|
||||||
|
<Avatar sx={{ width: 34, height: 34, bgcolor: RED, color: '#fff', fontWeight: 700 }}>{displayInitial}</Avatar>
|
||||||
|
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ color: 'grey.800', fontWeight: 600 }}>
|
||||||
|
{activeUserName}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', textTransform: 'capitalize' }}>
|
||||||
|
{displayRole}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
anchorEl={account}
|
||||||
|
open={Boolean(account)}
|
||||||
|
onClose={() => setAccount(null)}
|
||||||
|
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||||
|
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||||
|
PaperProps={{ sx: { mt: 1, minWidth: 220 } }}
|
||||||
|
>
|
||||||
|
<Box sx={{ px: 2, py: 1.5 }}>
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||||
|
<Avatar sx={{ width: 38, height: 38, bgcolor: RED, color: '#fff', fontWeight: 700 }}>{displayInitial}</Avatar>
|
||||||
|
<Box sx={{ lineHeight: 1.2 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{activeUserName}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'capitalize' }}>{displayRole}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<MenuItem onClick={() => { setAccount(null); navigate('/settings'); }}>
|
||||||
|
<ListItemIcon><SettingsOutlinedIcon fontSize="small" /></ListItemIcon>
|
||||||
|
Settings
|
||||||
|
</MenuItem>
|
||||||
|
<Divider />
|
||||||
|
<MenuItem onClick={() => { setAccount(null); localStorage.removeItem('auth_token'); navigate('/login'); }} sx={{ color: 'error.main' }}>
|
||||||
|
<ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon>
|
||||||
|
Logout
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,158 +1,204 @@
|
|||||||
import { useLocation } from 'react-router-dom';
|
import { useState } from 'react';
|
||||||
import { SideNav, SideNavSection, SideNavItem } from '@astryxdesign/core/SideNav';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import {
|
||||||
|
Drawer,
|
||||||
|
Box,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Typography,
|
||||||
|
Collapse,
|
||||||
|
Tooltip,
|
||||||
|
Toolbar,
|
||||||
|
alpha
|
||||||
|
} from '@mui/material';
|
||||||
|
import ExpandLess from '@mui/icons-material/ExpandLess';
|
||||||
|
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||||
|
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
||||||
|
|
||||||
import navItems from '@/menu/navItems';
|
import navItems from '@/menu/navItems';
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
|
||||||
// ==============================|| DOORMILE - SIDE NAV ||============================== //
|
export const DRAWER_WIDTH = 264;
|
||||||
// Thin wrapper around Astryx's SideNav template: sections + items driven by navItems.
|
export const MINI_WIDTH = 78;
|
||||||
// Branding lives in the TopNav heading, so this stays icon+label only (Astryx guidance:
|
|
||||||
// avoid a SideNavHeading when TopNav already carries app identity).
|
|
||||||
|
|
||||||
export default function Sidebar() {
|
const RED = '#C01227';
|
||||||
const location = useLocation();
|
|
||||||
const isActive = (url) => !!url && location.pathname.startsWith(url);
|
|
||||||
|
|
||||||
return (
|
function NavLeaf({ item, open, active, depth = 0, onClick }) {
|
||||||
<>
|
const Icon = item.icon;
|
||||||
<SideNav
|
const button = (
|
||||||
className="doormile-side-nav"
|
<ListItemButton
|
||||||
collapsible={{ defaultIsCollapsed: true, buttonLabel: 'Collapse navigation' }}
|
selected={active}
|
||||||
style={{
|
onClick={onClick}
|
||||||
// White + a right border/shadow matches the TopNav's chrome so the
|
sx={{
|
||||||
// top and side nav read as one unified surface instead of two
|
minHeight: 44,
|
||||||
// mismatched panels (TopNav is white with a bottom border already).
|
my: 0.25,
|
||||||
backgroundColor: '#ffffff',
|
mx: open ? 1 : 0.75,
|
||||||
borderRight: '1px solid rgba(5, 54, 89, 0.08)',
|
px: open ? 1.5 : 0,
|
||||||
boxShadow: '1px 0 3px rgba(15, 23, 42, 0.03)',
|
justifyContent: open ? 'flex-start' : 'center',
|
||||||
paddingBlock: '12px',
|
borderRadius: 2,
|
||||||
paddingInline: '8px',
|
color: active ? RED : 'grey.700',
|
||||||
boxSizing: 'border-box',
|
'& .MuiListItemIcon-root': { color: active ? RED : 'grey.500' },
|
||||||
// Astryx's collapsed-rail width reads var(--spacing-12) (48px default),
|
'&:hover': { bgcolor: alpha(RED, 0.04), color: RED, '& .MuiListItemIcon-root': { color: RED } },
|
||||||
// which feels cramped for our icons. Overriding the token on this
|
'&.Mui-selected': {
|
||||||
// element only widens the collapsed rail without touching the same
|
bgcolor: alpha(RED, 0.08),
|
||||||
// token's unrelated uses elsewhere (e.g. AppShell's mobile top bar).
|
color: RED,
|
||||||
'--spacing-12': '72px'
|
'& .MuiListItemIcon-root': { color: RED },
|
||||||
|
borderLeft: open ? `4px solid ${RED}` : 'none',
|
||||||
|
'&:hover': { bgcolor: alpha(RED, 0.12) }
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{navItems.map((grp) => (
|
<ListItemIcon sx={{ minWidth: open ? 34 : 'auto', justifyContent: 'center' }}>
|
||||||
<SideNavSection key={grp.group} title={grp.group}>
|
{depth > 0 && !Icon ? <FiberManualRecordIcon sx={{ fontSize: 8 }} /> : Icon ? <Icon fontSize="small" /> : null}
|
||||||
{grp.items.map((item) => (
|
</ListItemIcon>
|
||||||
<SideNavItem
|
{open && (
|
||||||
key={item.id}
|
<ListItemText
|
||||||
label={item.title}
|
primary={item.title}
|
||||||
icon={item.icon}
|
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: active ? 700 : 500 }}
|
||||||
href={item.url}
|
|
||||||
isSelected={isActive(item.url)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
)}
|
||||||
</SideNavSection>
|
</ListItemButton>
|
||||||
))}
|
);
|
||||||
</SideNav>
|
|
||||||
<style>{`
|
return open ? button : <Tooltip title={item.title} placement="right">{button}</Tooltip>;
|
||||||
/* Astryx packs items 2px apart by default, which reads as one
|
|
||||||
merged block on hover instead of distinct rows. The items
|
|
||||||
wrapper has no stable class of its own, but it's always the
|
|
||||||
section's second/last child div, right after the header. */
|
|
||||||
.doormile-side-nav .astryx-side-nav-section > div:last-child {
|
|
||||||
gap: 6px !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Collapsed nav items are the only ones Astryx renders with an
|
export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
||||||
aria-label on the item element itself (expanded rows show the
|
const location = useLocation();
|
||||||
label as visible text instead), so it doubles as a stable hook
|
const navigate = useNavigate();
|
||||||
for "collapsed only" styling without touching the expanded rail. */
|
const isActive = (url) => url && location.pathname.startsWith(url);
|
||||||
.doormile-side-nav .astryx-side-nav-item[aria-label] {
|
const expanded = open || isMobile;
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
margin-inline: auto;
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
}
|
|
||||||
.doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon {
|
|
||||||
width: 18px !important;
|
|
||||||
height: 18px !important;
|
|
||||||
}
|
|
||||||
.doormile-side-nav .astryx-side-nav-item[aria-label]:hover {
|
|
||||||
background-color: rgba(192, 18, 39, 0.08) !important;
|
|
||||||
}
|
|
||||||
.doormile-side-nav .astryx-side-nav-item[aria-label]:focus-visible {
|
|
||||||
outline: 2px solid rgba(192, 18, 39, 0.4);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
.doormile-side-nav .astryx-side-nav-item[aria-label][data-selected='selected'] {
|
|
||||||
background-color: rgba(192, 18, 39, 0.12) !important;
|
|
||||||
}
|
|
||||||
.doormile-side-nav .astryx-side-nav-item[aria-label][data-selected='selected'] .astryx-icon {
|
|
||||||
color: #C01227 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Expanded rows: rounded hover/selected states with a left accent
|
const initialOpen = navItems
|
||||||
bar on the active item, instead of the default flat highlight.
|
.flatMap((g) => g.items)
|
||||||
The accent bar is always present (via ::before) but only faded
|
.filter((i) => i.children && i.children.some((c) => isActive(c.url)))
|
||||||
in on selection, so clicking a link fades it in smoothly instead
|
.map((i) => i.id);
|
||||||
of the bar snapping into place flush against the row edges.
|
const [collapse, setCollapse] = useState(initialOpen);
|
||||||
Row sizing mirrors doormile_console's NavItem pattern (py: 1,
|
|
||||||
i.e. 8px top/bottom padding on the button itself) instead of a
|
|
||||||
fixed-height row: the highlight is painted directly on the item's
|
|
||||||
own (now-padded) background, so it naturally gets breathing room
|
|
||||||
above and below rather than filling a cramped fixed-height box
|
|
||||||
edge to edge. */
|
|
||||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label]) {
|
|
||||||
position: relative;
|
|
||||||
margin-inline: 2px;
|
|
||||||
height: auto;
|
|
||||||
padding-block: 8px !important;
|
|
||||||
transition: background-color 0.15s ease, transform 0.15s ease;
|
|
||||||
}
|
|
||||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -2px;
|
|
||||||
top: 8px;
|
|
||||||
bottom: 8px;
|
|
||||||
width: 3px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background-color: #C01227;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label]):hover {
|
|
||||||
background-color: rgba(192, 18, 39, 0.05) !important;
|
|
||||||
transform: translateX(2px);
|
|
||||||
}
|
|
||||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label]):focus-visible {
|
|
||||||
outline: 2px solid rgba(192, 18, 39, 0.4);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected'] {
|
|
||||||
background-color: rgba(192, 18, 39, 0.08) !important;
|
|
||||||
}
|
|
||||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected']::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected'] .astryx-icon {
|
|
||||||
color: #C01227 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Collapse/expand toggle (the "<" / ">" chevron button). It sits
|
const go = (url) => {
|
||||||
right on the sidebar's edge, so the default square ghost-button
|
navigate(url);
|
||||||
hover clips against that border and looks broken. Give it a
|
if (isMobile) onMobileClose();
|
||||||
clean circular hover detached from the edge instead. */
|
};
|
||||||
.doormile-side-nav button[aria-label*="sidebar"],
|
|
||||||
.doormile-side-nav button[aria-label*="navigation"] {
|
const content = (
|
||||||
border-radius: 50% !important;
|
<Box sx={{ bgcolor: '#fff', height: '100%', color: 'grey.800', display: 'flex', flexDirection: 'column', borderRight: 1, borderColor: 'divider' }}>
|
||||||
transition: background-color 0.15s ease !important;
|
<Toolbar sx={{ px: expanded ? 2.5 : 0, justifyContent: expanded ? 'flex-start' : 'center', minHeight: 64 }}>
|
||||||
transform: translateX(-6px) !important;
|
<Logo compact={!expanded} />
|
||||||
|
</Toolbar>
|
||||||
|
<Box sx={{ overflowY: 'auto', overflowX: 'hidden', flexGrow: 1, pb: 2 }}>
|
||||||
|
{navItems.map((grp) => (
|
||||||
|
<Box key={grp.group} sx={{ mt: 1 }}>
|
||||||
|
{expanded && (
|
||||||
|
<Typography
|
||||||
|
variant="overline"
|
||||||
|
sx={{ px: 2.5, color: '#A06060', fontSize: '0.6875rem', letterSpacing: '0.08em', fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
{grp.group}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<List disablePadding sx={{ mt: 0.5 }}>
|
||||||
|
{grp.items.map((item) => {
|
||||||
|
if (item.children) {
|
||||||
|
const opened = collapse.includes(item.id);
|
||||||
|
const childActive = item.children.some((c) => isActive(c.url));
|
||||||
|
const Icon = item.icon;
|
||||||
|
const head = (
|
||||||
|
<ListItemButton
|
||||||
|
onClick={() =>
|
||||||
|
expanded
|
||||||
|
? setCollapse((p) => (p.includes(item.id) ? p.filter((x) => x !== item.id) : [...p, item.id]))
|
||||||
|
: go(item.children[0].url)
|
||||||
}
|
}
|
||||||
.doormile-side-nav button[aria-label*="sidebar"]:hover,
|
sx={{
|
||||||
.doormile-side-nav button[aria-label*="navigation"]:hover {
|
minHeight: 44,
|
||||||
background-color: rgba(192, 18, 39, 0.08) !important;
|
my: 0.25,
|
||||||
}
|
mx: expanded ? 1 : 0.75,
|
||||||
`}</style>
|
px: expanded ? 1.5 : 0,
|
||||||
|
justifyContent: expanded ? 'flex-start' : 'center',
|
||||||
|
borderRadius: 2,
|
||||||
|
color: childActive ? RED : 'grey.700',
|
||||||
|
bgcolor: childActive && !opened ? alpha(RED, 0.08) : 'transparent',
|
||||||
|
'& .MuiListItemIcon-root': { color: childActive ? RED : 'grey.500' },
|
||||||
|
'&:hover': { bgcolor: alpha(RED, 0.04), color: RED, '& .MuiListItemIcon-root': { color: RED } }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon sx={{ minWidth: expanded ? 34 : 'auto', justifyContent: 'center' }}>
|
||||||
|
<Icon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
{expanded && (
|
||||||
|
<>
|
||||||
|
<ListItemText primary={item.title} primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: 500 }} />
|
||||||
|
{opened ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Box key={item.id}>
|
||||||
|
{expanded ? head : <Tooltip title={item.title} placement="right">{head}</Tooltip>}
|
||||||
|
{expanded && (
|
||||||
|
<Collapse in={opened} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ pl: 1.5 }}>
|
||||||
|
{item.children.map((c) => (
|
||||||
|
<NavLeaf key={c.id} item={c} open depth={1} active={isActive(c.url)} onClick={() => go(c.url)} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<NavLeaf key={item.id} item={item} open={expanded} active={isActive(item.url)} onClick={() => go(item.url)} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
{expanded && (
|
||||||
|
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
Doormile CRM v1.0
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
variant="temporary"
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={onMobileClose}
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
|
sx={{ '& .MuiDrawer-paper': { width: DRAWER_WIDTH, border: 'none' } }}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
width: open ? DRAWER_WIDTH : MINI_WIDTH,
|
||||||
|
flexShrink: 0,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
'& .MuiDrawer-paper': {
|
||||||
|
width: open ? DRAWER_WIDTH : MINI_WIDTH,
|
||||||
|
border: 'none',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
transition: (t) => t.transitions.create('width', { duration: t.transitions.duration.standard })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
open={open}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Drawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,45 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { AppShell } from '@astryxdesign/core/AppShell';
|
import { Box, Toolbar, useMediaQuery } from '@mui/material';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
import Sidebar from './Sidebar';
|
import Sidebar, { DRAWER_WIDTH, MINI_WIDTH } from './Sidebar';
|
||||||
|
|
||||||
// ==============================|| DOORMILE - APP SHELL ||============================== //
|
|
||||||
// Astryx AppShell owns the responsive chrome: sticky top nav, collapsible side nav,
|
|
||||||
// and the auto-generated mobile drawer. Pages keep their own internal padding.
|
|
||||||
|
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('lg'));
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (isMobile) setMobileOpen((p) => !p);
|
||||||
|
else setOpen((p) => !p);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<Box sx={{ display: 'flex', bgcolor: 'background.default', minHeight: '100vh' }}>
|
||||||
variant="section"
|
<Header onToggle={toggle} />
|
||||||
height="fill"
|
<Sidebar
|
||||||
contentPadding={0}
|
open={open}
|
||||||
topNav={<Header />}
|
isMobile={isMobile}
|
||||||
sideNav={<Sidebar />}
|
mobileOpen={mobileOpen}
|
||||||
mobileNav={{ breakpoint: 'lg' }}
|
onMobileClose={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
width: { lg: `calc(100% - ${open ? DRAWER_WIDTH : MINI_WIDTH}px)` },
|
||||||
|
minHeight: '100vh',
|
||||||
|
transition: theme.transitions.create('width', { duration: theme.transitions.duration.standard })
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="main-content-area" style={{ minHeight: '100%', boxSizing: 'border-box' }}>
|
<Toolbar sx={{ minHeight: 64 }} />
|
||||||
|
<Box sx={{ p: { xs: 2, sm: 3 } }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</Box>
|
||||||
<style>{`
|
</Box>
|
||||||
.main-content-area {
|
</Box>
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.main-content-area {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</AppShell>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
|
||||||
// Used by auth + maintenance pages — full-bleed, no shell.
|
// Used by auth + maintenance pages — full-bleed, no shell.
|
||||||
export default function MinimalLayout() {
|
export default function MinimalLayout() {
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', backgroundColor: '#f8fafc' }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/main.jsx
26
src/main.jsx
@@ -1,28 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { BrowserRouter, Link } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||||
|
import { LocalizationProvider } from '@mui/x-date-pickers';
|
||||||
|
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||||
|
|
||||||
// Astryx UI CSS
|
import theme from '@/theme';
|
||||||
import '@astryxdesign/core/reset.css';
|
|
||||||
import '@astryxdesign/core/astryx.css';
|
|
||||||
import '@astryxdesign/theme-neutral/theme.css';
|
|
||||||
import './index.css';
|
|
||||||
|
|
||||||
import { Theme } from '@astryxdesign/core';
|
|
||||||
import { LinkProvider } from '@astryxdesign/core/Link';
|
|
||||||
import { neutralTheme } from '@astryxdesign/theme-neutral/built';
|
|
||||||
import App from '@/App';
|
import App from '@/App';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<Theme theme={neutralTheme}>
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
{/* Makes every Astryx nav/link component (SideNavItem, TopNavHeading, DropdownMenu, ...)
|
|
||||||
route through React Router's Link instead of a hard <a> reload. */}
|
|
||||||
<LinkProvider component={Link}>
|
|
||||||
<App />
|
<App />
|
||||||
</LinkProvider>
|
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</Theme>
|
</LocalizationProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { LayoutDashboard, Building2, Users, Settings, DollarSign, Calendar } from 'lucide-react';
|
import DashboardOutlinedIcon from '@mui/icons-material/DashboardOutlined';
|
||||||
|
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
|
||||||
|
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
||||||
|
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
|
||||||
|
import AttachMoneyOutlinedIcon from '@mui/icons-material/AttachMoneyOutlined';
|
||||||
|
|
||||||
// ==============================|| DOORMILE - SIDEBAR NAV CONFIG ||============================== //
|
// ==============================|| DOORMILE - SIDEBAR NAV CONFIG ||============================== //
|
||||||
|
|
||||||
@@ -6,18 +10,18 @@ const navItems = [
|
|||||||
{
|
{
|
||||||
group: 'CRM',
|
group: 'CRM',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'dashboard', title: 'Dashboard', url: '/dashboard', icon: LayoutDashboard },
|
{ id: 'dashboard', title: 'Dashboard', url: '/dashboard', icon: DashboardOutlinedIcon },
|
||||||
{ id: 'tenants', title: 'Clients', url: '/clients', icon: Building2 },
|
{ id: 'tenants', title: 'Clients', url: '/tenants', icon: ApartmentOutlinedIcon },
|
||||||
{ id: 'survey', title: 'Providers', url: '/survey', icon: Users },
|
{ id: 'survey', title: 'Providers', url: '/survey', icon: DashboardOutlinedIcon },
|
||||||
{ id: 'pricing', title: 'Pricing Matrix', url: '/pricing', icon: DollarSign },
|
{ id: 'pricing', title: 'Pricing Matrix', url: '/pricing', icon: AttachMoneyOutlinedIcon },
|
||||||
{ id: 'bookings', title: 'Bookings', url: '/bookings', icon: Calendar },
|
{ id: 'bookings', title: 'Bookings', url: '/bookings', icon: DashboardOutlinedIcon },
|
||||||
{ id: 'team-users', title: 'App Users', url: '/team-users', icon: Users }
|
{ id: 'team-users', title: 'App Users', url: '/team-users', icon: GroupsOutlinedIcon }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'System',
|
group: 'System',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'settings', title: 'Settings', url: '/settings', icon: Settings }
|
{ id: 'settings', title: 'Settings', url: '/settings', icon: SettingsOutlinedIcon }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import {
|
import {
|
||||||
Building2,
|
Grid, Card, Box, Stack, Typography, Button, Divider, LinearProgress, CircularProgress, Alert,
|
||||||
UserPlus,
|
Table, TableBody, TableCell, TableHead, TableRow, TableContainer, useMediaQuery
|
||||||
Package,
|
} from '@mui/material';
|
||||||
Handshake,
|
import { useTheme } from '@mui/material/styles';
|
||||||
History,
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
PieChart,
|
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
|
||||||
Layers,
|
import FiberNewOutlinedIcon from '@mui/icons-material/FiberNewOutlined';
|
||||||
Users,
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
ArrowRight,
|
import HandshakeOutlinedIcon from '@mui/icons-material/HandshakeOutlined';
|
||||||
Calendar
|
import HistoryOutlinedIcon from '@mui/icons-material/HistoryOutlined';
|
||||||
} from 'lucide-react';
|
import DonutLargeOutlinedIcon from '@mui/icons-material/DonutLargeOutlined';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
||||||
import { Table, proportional, pixel } from '@astryxdesign/core/Table';
|
|
||||||
import { Badge } from '@astryxdesign/core/Badge';
|
|
||||||
import { Text, Heading } from '@astryxdesign/core/Text';
|
|
||||||
import { Banner } from '@astryxdesign/core/Banner';
|
|
||||||
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
import StatCard from '@/components/StatCard';
|
import StatCard from '@/components/StatCard';
|
||||||
@@ -36,86 +30,50 @@ const BAR_COLORS = ['#C01227', '#00A2AE', '#00A854', '#FFBF00', '#9E0E20', '#8C8
|
|||||||
|
|
||||||
function Panel({ icon: Icon, title, action, color = 'primary', noPadding = false, children }) {
|
function Panel({ icon: Icon, title, action, color = 'primary', noPadding = false, children }) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card sx={{
|
||||||
style={{
|
|
||||||
height: '100%',
|
height: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
borderRadius: '16px',
|
borderRadius: 4,
|
||||||
border: '1px solid rgba(5, 54, 89, 0.08)',
|
boxShadow: '0 10px 30px rgba(0,0,0,0.03)',
|
||||||
backgroundColor: '#ffffff',
|
border: '1px solid rgba(0,0,0,0.04)'
|
||||||
overflow: 'hidden',
|
}}>
|
||||||
boxShadow: '0 10px 30px -5px rgba(0,0,0,0.02), 0 4px 12px -3px rgba(0,0,0,0.02)',
|
<Stack
|
||||||
boxSizing: 'border-box'
|
direction="row" spacing={1.5} alignItems="center"
|
||||||
|
sx={{
|
||||||
|
px: { xs: 2, sm: 3 }, py: 2, borderBottom: 1, borderColor: 'divider',
|
||||||
|
background: (theme) => `linear-gradient(90deg, ${theme.palette[color].lighter}66 0%, transparent 100%)`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Box sx={{ width: 40, height: 40, borderRadius: 2, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: `${color}.lighter`, color: `${color}.main`, boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.05)' }}>
|
||||||
style={{
|
<Icon fontSize="small" />
|
||||||
display: 'flex',
|
</Box>
|
||||||
flexDirection: 'row',
|
<Typography variant="h6" sx={{ fontWeight: 700, color: 'grey.800', flexGrow: 1, letterSpacing: '-0.3px' }}>{title}</Typography>
|
||||||
alignItems: 'center',
|
|
||||||
padding: '10px 16px',
|
|
||||||
borderBottom: '1px solid rgba(5, 54, 89, 0.06)',
|
|
||||||
background: 'linear-gradient(90deg, rgba(192, 18, 39, 0.03) 0%, rgba(255, 255, 255, 0) 100%)',
|
|
||||||
gap: '10px',
|
|
||||||
flexWrap: 'wrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '30px',
|
|
||||||
height: '30px',
|
|
||||||
borderRadius: '7px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: 'rgba(192, 18, 39, 0.08)',
|
|
||||||
color: '#C01227',
|
|
||||||
flexShrink: 0
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon size={15} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flexGrow: 1, minWidth: 0 }}>
|
|
||||||
<Heading level={5}>{title}</Heading>
|
|
||||||
</div>
|
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</Stack>
|
||||||
<div style={{ padding: noPadding ? '0' : '16px', flexGrow: 1, boxSizing: 'border-box' }}>
|
<Box sx={{ p: noPadding ? 0 : { xs: 2, sm: 3 }, flexGrow: 1 }}>{children}</Box>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const navigate = useNavigate();
|
const theme = useTheme();
|
||||||
const [selectedDate, setSelectedDate] = useState(() => dayjs().format('YYYY-MM-DD'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
const [clients, setClients] = useState([]);
|
const [clients, setClients] = useState([]);
|
||||||
const [team, setTeam] = useState([]);
|
const [team, setTeam] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
// Guards against out-of-order responses (e.g. React StrictMode's double-invoked
|
|
||||||
// effect) so a stale request can never clobber a newer one's result.
|
|
||||||
const loadRequestId = useRef(0);
|
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
const requestId = ++loadRequestId.current;
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
Promise.all([fetchClients(), fetchUsers()])
|
Promise.all([fetchClients(), fetchUsers()])
|
||||||
.then(([cs, us]) => {
|
.then(([cs, us]) => {
|
||||||
if (loadRequestId.current !== requestId) return;
|
|
||||||
setClients((cs || []).map(toClient));
|
setClients((cs || []).map(toClient));
|
||||||
setTeam((us || []).map(toUser));
|
setTeam((us || []).map(toUser));
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => setError(e.message || 'Failed to load dashboard data'))
|
||||||
if (loadRequestId.current === requestId) setError(e.message || 'Failed to load dashboard data');
|
.finally(() => setLoading(false));
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (loadRequestId.current === requestId) setLoading(false);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
@@ -145,273 +103,148 @@ export default function Dashboard() {
|
|||||||
[clients]
|
[clients]
|
||||||
);
|
);
|
||||||
|
|
||||||
const recentColumns = useMemo(() => [
|
const today = new Date().toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
header: <div style={{ paddingLeft: '24px' }}>Client</div>,
|
|
||||||
width: proportional(2),
|
|
||||||
renderCell: (c) => (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', paddingLeft: '24px' }}>
|
|
||||||
<UserAvatar name={c.name} size={32} />
|
|
||||||
<Text type="body" weight="semibold">{c.name}</Text>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'businessType',
|
|
||||||
header: 'Type',
|
|
||||||
width: proportional(1),
|
|
||||||
renderCell: (c) => c.businessType
|
|
||||||
? <Badge variant="neutral" label={titleCase(c.businessType)} />
|
|
||||||
: <Text type="supporting" color="disabled">—</Text>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'location',
|
|
||||||
header: 'Location',
|
|
||||||
width: proportional(1),
|
|
||||||
renderCell: (c) => (
|
|
||||||
<Text type="body">{`${c.city || '—'}${c.businessState ? `, ${c.businessState}` : ''}`}</Text>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'parcelVolume',
|
|
||||||
header: 'Parcels',
|
|
||||||
width: pixel(110),
|
|
||||||
align: 'end',
|
|
||||||
renderCell: (c) => <Text type="body" weight="semibold" hasTabularNumbers>{c.parcelVolume.toLocaleString('en-IN')}</Text>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'Status',
|
|
||||||
width: pixel(140),
|
|
||||||
renderCell: (c) => <StatusChip status={c.status} />
|
|
||||||
}
|
|
||||||
], []);
|
|
||||||
|
|
||||||
const datePickerAction = (
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className="dashboard-date-picker"
|
|
||||||
value={selectedDate}
|
|
||||||
onChange={(e) => setSelectedDate(e.target.value)}
|
|
||||||
style={{
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: '10px',
|
|
||||||
border: '1px solid rgba(5, 54, 89, 0.12)',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
color: '#1e293b',
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
outline: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.04)',
|
|
||||||
width: '150px'
|
|
||||||
}}
|
|
||||||
onFocus={(e) => {
|
|
||||||
e.target.style.borderColor = '#C01227';
|
|
||||||
e.target.style.boxShadow = '0 0 0 3px rgba(192, 18, 39, 0.12)';
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
e.target.style.borderColor = 'rgba(5, 54, 89, 0.12)';
|
|
||||||
e.target.style.boxShadow = '0 1px 2px rgba(15, 23, 42, 0.04)';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
<>
|
||||||
<PageHeader title="Dashboard" action={datePickerAction} />
|
<PageHeader title="Dashboard" breadcrumbs={[{ label: 'Dashboard' }]} />
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '96px 0' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 12 }}><CircularProgress /></Box>
|
||||||
<div className="spinner" style={{ width: '40px', height: '40px', border: '4px solid #f1f5f9', borderTop: '4px solid #C01227', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
</>
|
||||||
<style>{`@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`}</style>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
<>
|
||||||
<PageHeader title="Dashboard" action={datePickerAction} />
|
<PageHeader
|
||||||
|
title="Dashboard"
|
||||||
{error && (
|
breadcrumbs={[{ label: 'Dashboard' }]}
|
||||||
<div style={{ marginBottom: '24px' }}>
|
action={<Button variant="outlined" startIcon={<RefreshIcon />} onClick={load}>Refresh</Button>}
|
||||||
<Banner
|
|
||||||
status="error"
|
|
||||||
title={error}
|
|
||||||
endContent={<Button label="Retry" variant="ghost" size="sm" onClick={load} />}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '12px', marginBottom: '16px' }}>
|
{error && <Alert severity="error" sx={{ mb: 2.5 }} action={<Button color="inherit" size="small" onClick={load}>Retry</Button>}>{error}</Alert>}
|
||||||
<StatCard size="sm" title="Total Clients" value={stats.total} icon={Building2} color="primary" />
|
|
||||||
<StatCard size="sm" title="New Clients" value={stats.newCount} icon={UserPlus} color="info" />
|
|
||||||
<StatCard size="sm" title="Total Parcel Volume" value={stats.parcels.toLocaleString('en-IN')} icon={Package} color="success" />
|
|
||||||
<StatCard size="sm" title="Active Contracts" value={stats.contracts} icon={Handshake} color="warning" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main dashboard panels */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '16px', marginBottom: '16px' }}>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', lg: 'repeat(12, 1fr)', gap: '16px' }} className="responsive-row">
|
<Grid container spacing={3}>
|
||||||
{/* Recent Clients */}
|
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Clients" value={stats.total} icon={ApartmentOutlinedIcon} color="primary" caption="All registered" /></Grid>
|
||||||
<div style={{ gridColumn: 'span 8' }} className="col-span-8">
|
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="New Clients" value={stats.newCount} icon={FiberNewOutlinedIcon} color="primary" caption="Awaiting onboarding" /></Grid>
|
||||||
<Panel
|
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Parcel Volume" value={stats.parcels.toLocaleString('en-IN')} icon={Inventory2OutlinedIcon} color="primary" caption="Across all clients" /></Grid>
|
||||||
icon={History}
|
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Active Contracts" value={stats.contracts} icon={HandshakeOutlinedIcon} color="primary" caption="Currently running" /></Grid>
|
||||||
title="Recent Clients"
|
|
||||||
color="primary"
|
<Grid item xs={12} lg={8}>
|
||||||
noPadding
|
<Panel icon={HistoryOutlinedIcon} title="Recent Clients" color="primary" noPadding>
|
||||||
action={recent.length > 0 && (
|
|
||||||
<Button
|
|
||||||
label="View all"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
icon={<ArrowRight size={14} />}
|
|
||||||
onClick={() => navigate('/clients')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{recent.length === 0 ? (
|
{recent.length === 0 ? (
|
||||||
<EmptyState icon={Building2} title="No clients yet" caption="Add a client under the Clients menu to see it here." />
|
<EmptyState title="No clients yet" caption="Add a client to see it here." />
|
||||||
) : (
|
) : isMobile ? (
|
||||||
<div style={{ paddingTop: '8px', paddingBottom: '8px' }}>
|
<Stack divider={<Divider />}>
|
||||||
<Table
|
{recent.map((c) => (
|
||||||
data={recent}
|
<Stack key={c.id} direction="row" spacing={1.25} alignItems="center" sx={{ px: 2, py: 1.5 }}>
|
||||||
columns={recentColumns}
|
<UserAvatar name={c.name} size={36} />
|
||||||
idKey="id"
|
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
|
||||||
density="compact"
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }} noWrap>{c.name}</Typography>
|
||||||
dividers="rows"
|
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>
|
||||||
hasHover
|
{[titleCase(c.businessType), c.city].filter(Boolean).join(' · ') || '—'} · {c.parcelVolume.toLocaleString('en-IN')} parcels
|
||||||
isStriped
|
</Typography>
|
||||||
textOverflow="truncate"
|
</Box>
|
||||||
/>
|
<StatusChip status={c.status} />
|
||||||
</div>
|
</Stack>
|
||||||
)}
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Clients by Status */}
|
|
||||||
<div style={{ gridColumn: 'span 4' }} className="col-span-4">
|
|
||||||
<Panel icon={PieChart} title="Clients by Status" color="primary">
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
|
|
||||||
{statusData.length === 0 ? (
|
|
||||||
<EmptyState icon={PieChart} title="No status breakdown" caption="No client status distribution available." />
|
|
||||||
) : (
|
|
||||||
<DonutChart data={statusData} size={140} thickness={20} centerValue={stats.total} centerLabel="Clients" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', lg: 'repeat(12, 1fr)', gap: '16px' }} className="responsive-row">
|
|
||||||
{/* Business Type bar chart */}
|
|
||||||
<div style={{ gridColumn: 'span 8' }} className="col-span-8">
|
|
||||||
<Panel icon={Layers} title="Clients by Business Type" color="primary">
|
|
||||||
{byType.length === 0 ? (
|
|
||||||
<EmptyState icon={Layers} title="No business types" caption="No business type distribution available." />
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
||||||
{byType.map(([type, count], i) => (
|
|
||||||
<div key={type}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
||||||
<div style={{ width: '10px', height: '10px', borderRadius: '3px', backgroundColor: BAR_COLORS[i % BAR_COLORS.length] }} />
|
|
||||||
<Text type="body" weight="semibold">{titleCase(type)}</Text>
|
|
||||||
</div>
|
|
||||||
<Text type="body" color="secondary" hasTabularNumbers>
|
|
||||||
{count} · {Math.round((count / clients.length) * 100)}%
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<div style={{ height: '6px', borderRadius: '3px', backgroundColor: '#f1f5f9', width: '100%', overflow: 'hidden' }}>
|
|
||||||
<div style={{ height: '100%', borderRadius: '3px', backgroundColor: BAR_COLORS[i % BAR_COLORS.length], width: `${(count / maxType) * 100}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<TableContainer>
|
||||||
|
<Table sx={{ minWidth: 600 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', textTransform: 'uppercase', fontSize: '0.72rem', letterSpacing: 0.4 } }}>
|
||||||
|
<TableCell>Client</TableCell>
|
||||||
|
<TableCell>Type</TableCell>
|
||||||
|
<TableCell>Location</TableCell>
|
||||||
|
<TableCell align="right">Parcels</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{recent.map((c) => (
|
||||||
|
<TableRow key={c.id} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||||
|
<UserAvatar name={c.name} size={32} />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }}>{c.name}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell><Typography variant="body2">{titleCase(c.businessType) || '—'}</Typography></TableCell>
|
||||||
|
<TableCell><Typography variant="body2">{c.city || '—'}{c.businessState ? `, ${c.businessState}` : ''}</Typography></TableCell>
|
||||||
|
<TableCell align="right" sx={{ fontWeight: 600 }}>{c.parcelVolume.toLocaleString('en-IN')}</TableCell>
|
||||||
|
<TableCell><StatusChip status={c.status} /></TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</Grid>
|
||||||
|
|
||||||
{/* App Users */}
|
<Grid item xs={12} lg={4}>
|
||||||
<div style={{ gridColumn: 'span 4' }} className="col-span-4">
|
<Panel icon={DonutLargeOutlinedIcon} title="Clients by Status" color="primary">
|
||||||
<Panel
|
<Box sx={{ py: 1.5 }}>
|
||||||
icon={Users}
|
{statusData.length === 0
|
||||||
title="App Users"
|
? <EmptyState title="No data" />
|
||||||
color="primary"
|
: <DonutChart data={statusData} centerValue={stats.total} centerLabel="Clients" />}
|
||||||
>
|
</Box>
|
||||||
|
</Panel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} lg={8}>
|
||||||
|
<Panel icon={CategoryOutlinedIcon} title="Clients by Business Type" color="primary">
|
||||||
|
{byType.length === 0 ? (
|
||||||
|
<EmptyState title="No data" />
|
||||||
|
) : (
|
||||||
|
<Stack spacing={2.25}>
|
||||||
|
{byType.map(([type, count], i) => (
|
||||||
|
<Box key={type}>
|
||||||
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 0.75 }}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<Box sx={{ width: 10, height: 10, borderRadius: '3px', bgcolor: BAR_COLORS[i % BAR_COLORS.length] }} />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }}>{titleCase(type)}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{count} · {Math.round((count / clients.length) * 100)}%
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={(count / maxType) * 100}
|
||||||
|
sx={{ height: 8, borderRadius: 4, bgcolor: 'grey.100', '& .MuiLinearProgress-bar': { borderRadius: 4, backgroundColor: BAR_COLORS[i % BAR_COLORS.length] } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} lg={4}>
|
||||||
|
<Panel icon={GroupsOutlinedIcon} title={`App Users · ${team.length}`} color="primary">
|
||||||
{team.length === 0 ? (
|
{team.length === 0 ? (
|
||||||
<EmptyState title="No team users" />
|
<EmptyState title="No team users" />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
<Stack divider={<Divider />} spacing={0}>
|
||||||
{team.slice(0, 6).map((u) => (
|
{team.slice(0, 6).map((u) => (
|
||||||
<div
|
<Stack key={u.id} direction="row" spacing={1.5} alignItems="center" sx={{ py: 1.25 }}>
|
||||||
key={u.id}
|
<UserAvatar name={u.name} size={36} />
|
||||||
style={{
|
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||||
display: 'flex',
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>{u.name}</Typography>
|
||||||
alignItems: 'center',
|
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>{u.email}</Typography>
|
||||||
gap: '10px',
|
</Box>
|
||||||
padding: '8px 12px',
|
|
||||||
margin: '1px -12px',
|
|
||||||
borderRadius: '10px',
|
|
||||||
transition: 'background-color 0.2s ease',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}
|
|
||||||
className="team-row-hover"
|
|
||||||
>
|
|
||||||
<UserAvatar name={u.name} size={30} />
|
|
||||||
<div style={{ flexGrow: 1, minWidth: 0 }}>
|
|
||||||
<Text type="body" weight="semibold" maxLines={1}>{u.name}</Text>
|
|
||||||
<Text type="supporting" color="secondary" maxLines={1}>{u.email}</Text>
|
|
||||||
</div>
|
|
||||||
<StatusChip status={u.role} />
|
<StatusChip status={u.role} />
|
||||||
</div>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</Grid>
|
||||||
</div>
|
</Grid>
|
||||||
</div>
|
</>
|
||||||
|
|
||||||
<style>{`
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.responsive-row {
|
|
||||||
grid-template-columns: repeat(12, 1fr) !important;
|
|
||||||
}
|
|
||||||
.col-span-8 {
|
|
||||||
grid-column: span 8 !important;
|
|
||||||
}
|
|
||||||
.col-span-4 {
|
|
||||||
grid-column: span 4 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.responsive-row {
|
|
||||||
grid-template-columns: 1fr !important;
|
|
||||||
}
|
|
||||||
.col-span-8, .col-span-4 {
|
|
||||||
grid-column: span 1 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stat card hover transition effects */
|
|
||||||
.stat-card {
|
|
||||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
|
||||||
}
|
|
||||||
.stat-card:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08) !important;
|
|
||||||
border-color: rgba(192, 18, 39, 0.2) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Team member row hover polish */
|
|
||||||
.team-row-hover:hover {
|
|
||||||
background-color: rgba(192, 18, 39, 0.04) !important;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Save,
|
Grid, Card, Box, Stack, TextField, MenuItem, Switch, Button, Typography, Divider,
|
||||||
Sliders,
|
Snackbar, Alert, Chip, IconButton, InputAdornment, LinearProgress, Avatar
|
||||||
Bell,
|
} from '@mui/material';
|
||||||
Lock,
|
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||||
Shield,
|
import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined';
|
||||||
Megaphone,
|
import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone';
|
||||||
Building,
|
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||||
CheckCircle2,
|
import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined';
|
||||||
AlertTriangle,
|
import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined';
|
||||||
HelpCircle,
|
import BusinessOutlinedIcon from '@mui/icons-material/BusinessOutlined';
|
||||||
LogOut,
|
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||||
Eye,
|
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
|
||||||
EyeOff
|
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
|
||||||
} from 'lucide-react';
|
import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import Visibility from '@mui/icons-material/Visibility';
|
||||||
import { List, ListItem } from '@astryxdesign/core/List';
|
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||||
import { Text, Heading } from '@astryxdesign/core/Text';
|
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
|
||||||
import { Switch } from '@astryxdesign/core/Switch';
|
|
||||||
import { Badge } from '@astryxdesign/core/Badge';
|
|
||||||
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
|
|
||||||
@@ -41,9 +36,9 @@ const INITIAL_NOTIFY = {
|
|||||||
const INITIAL_SECURITY = { currentPassword: '', newPassword: '', confirmPassword: '', twoFactor: false };
|
const INITIAL_SECURITY = { currentPassword: '', newPassword: '', confirmPassword: '', twoFactor: false };
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ icon: Sliders, label: 'General', desc: 'Organisation profile' },
|
{ icon: TuneOutlinedIcon, label: 'General', desc: 'Organisation profile' },
|
||||||
{ icon: Bell, label: 'Notifications', desc: 'Alerts & channels' },
|
{ icon: NotificationsNoneIcon, label: 'Notifications', desc: 'Alerts & channels' },
|
||||||
{ icon: Lock, label: 'Security', desc: 'Password & 2FA' }
|
{ icon: LockOutlinedIcon, label: 'Security', desc: 'Password & 2FA' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const NOTIFY_ROWS = [
|
const NOTIFY_ROWS = [
|
||||||
@@ -54,13 +49,12 @@ const NOTIFY_ROWS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const STRENGTH = [
|
const STRENGTH = [
|
||||||
{ label: 'Too weak', color: '#ef4444' },
|
{ label: 'Too weak', color: 'error' },
|
||||||
{ label: 'Weak', color: '#ef4444' },
|
{ label: 'Weak', color: 'error' },
|
||||||
{ label: 'Fair', color: '#f59e0b' },
|
{ label: 'Fair', color: 'warning' },
|
||||||
{ label: 'Good', color: '#3b82f6' },
|
{ label: 'Good', color: 'info' },
|
||||||
{ label: 'Strong', color: '#10b981' }
|
{ label: 'Strong', color: 'success' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const scorePassword = (pw) => {
|
const scorePassword = (pw) => {
|
||||||
let s = 0;
|
let s = 0;
|
||||||
if (pw.length >= 8) s++;
|
if (pw.length >= 8) s++;
|
||||||
@@ -70,89 +64,63 @@ const scorePassword = (pw) => {
|
|||||||
return s;
|
return s;
|
||||||
};
|
};
|
||||||
|
|
||||||
function Section({ icon: Icon, title, subtitle, danger = false, children }) {
|
// Section surface with a tinted icon header band.
|
||||||
|
function Section({ icon: Icon, title, subtitle, color = 'primary', danger = false, children }) {
|
||||||
return (
|
return (
|
||||||
<Card style={{ padding: '0', borderRadius: '16px', border: danger ? '1px solid #fca5a5' : '1px solid rgba(5, 54, 89, 0.08)', backgroundColor: '#ffffff', overflow: 'hidden' }}>
|
<Card sx={danger ? { borderColor: 'error.light' } : undefined}>
|
||||||
<div
|
<Stack
|
||||||
style={{
|
direction="row" spacing={1.75} alignItems="center"
|
||||||
display: 'flex',
|
sx={{
|
||||||
flexDirection: 'row',
|
px: { xs: 2, sm: 3 }, py: 2.25, borderBottom: 1, borderColor: 'divider',
|
||||||
alignItems: 'center',
|
background: (theme) => `linear-gradient(90deg, ${theme.palette[color].lighter}66 0%, ${theme.palette.background.paper} 72%)`
|
||||||
padding: '18px 24px',
|
|
||||||
borderBottom: '1px solid rgba(5, 54, 89, 0.06)',
|
|
||||||
background: danger ? 'linear-gradient(90deg, rgba(239, 68, 68, 0.04) 0%, #ffffff 72%)' : 'linear-gradient(90deg, rgba(192, 18, 39, 0.04) 0%, #ffffff 72%)',
|
|
||||||
gap: '12px'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ width: '40px', height: '40px', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: danger ? 'rgba(239, 68, 68, 0.08)' : 'rgba(192, 18, 39, 0.08)', color: danger ? '#ef4444' : '#C01227' }}>
|
<Box sx={{ width: 40, height: 40, borderRadius: 2, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: `${color}.lighter`, color: `${color}.main` }}>
|
||||||
<Icon size={18} />
|
<Icon fontSize="small" />
|
||||||
</div>
|
</Box>
|
||||||
<div>
|
<Box sx={{ minWidth: 0 }}>
|
||||||
<Heading level={5}>{title}</Heading>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.3 }}>{title}</Typography>
|
||||||
{subtitle && <Text type="supporting" color="secondary">{subtitle}</Text>}
|
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Stack>
|
||||||
<div style={{ padding: '4px 24px' }}>{children}</div>
|
<Box sx={{ px: { xs: 2, sm: 3 } }}>{children}</Box>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({ label, description, controlAlign = 'stretch', children }) {
|
// Two-column row: label + helper on the left, control on the right.
|
||||||
|
function Row({ label, description, children, align = 'center' }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', padding: '16px 0', borderBottom: '1px solid #f1f5f9', flexWrap: 'wrap', gap: '12px' }} className="settings-row">
|
<Grid
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', width: '240px', flexShrink: 0 }}>
|
container spacing={2} alignItems={align}
|
||||||
<Text type="body" weight="semibold">{label}</Text>
|
sx={{ py: 2.5, borderRadius: 2, transition: 'background-color .15s', '&:hover': { bgcolor: 'grey.50' } }}
|
||||||
{description && <Text type="supporting" color="secondary">{description}</Text>}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flexGrow: 1,
|
|
||||||
minWidth: '220px',
|
|
||||||
display: controlAlign === 'end' ? 'flex' : 'block',
|
|
||||||
justifyContent: controlAlign === 'end' ? 'flex-end' : undefined
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{children}
|
<Grid item xs={12} sm={5}>
|
||||||
</div>
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'grey.800' }}>{label}</Typography>
|
||||||
</div>
|
{description && <Typography variant="caption" color="text.secondary">{description}</Typography>}
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={7}>{children}</Grid>
|
||||||
|
</Grid>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PasswordField({ label, value, onChange }) {
|
const rightAlign = { display: 'flex', justifyContent: { sm: 'flex-end' } };
|
||||||
|
|
||||||
|
function PasswordField({ label, value, onChange, autoComplete }) {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', width: '100%' }}>
|
<TextField
|
||||||
<TextInput
|
fullWidth size="small" type={show ? 'text' : 'password'} label={label} value={value} onChange={onChange} autoComplete={autoComplete}
|
||||||
label={label}
|
InputProps={{
|
||||||
isLabelHidden
|
endAdornment: (
|
||||||
type={show ? 'text' : 'password'}
|
<InputAdornment position="end">
|
||||||
placeholder={label}
|
<IconButton onClick={() => setShow((s) => !s)} edge="end" size="small">
|
||||||
value={value}
|
{show ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
|
||||||
onChange={onChange}
|
</IconButton>
|
||||||
/>
|
</InputAdornment>
|
||||||
<button
|
)
|
||||||
type="button"
|
|
||||||
onClick={() => setShow((s) => !s)}
|
|
||||||
aria-label={show ? 'Hide password' : 'Show password'}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: '10px',
|
|
||||||
bottom: 0,
|
|
||||||
height: '32px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: '#64748b',
|
|
||||||
padding: 0,
|
|
||||||
zIndex: 5
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,11 +133,11 @@ export default function Settings() {
|
|||||||
const [notify, setNotify] = useState(INITIAL_NOTIFY);
|
const [notify, setNotify] = useState(INITIAL_NOTIFY);
|
||||||
const [security, setSecurity] = useState(INITIAL_SECURITY);
|
const [security, setSecurity] = useState(INITIAL_SECURITY);
|
||||||
|
|
||||||
const setG = (k) => (val) => { setGeneral((p) => ({ ...p, [k]: val })); setDirty(true); };
|
const setG = (k) => (e) => { setGeneral((p) => ({ ...p, [k]: e.target.value })); setDirty(true); };
|
||||||
const setN = (k) => (val) => { setNotify((p) => ({ ...p, [k]: val })); setDirty(true); };
|
const setN = (k) => (e) => { setNotify((p) => ({ ...p, [k]: e.target.checked })); setDirty(true); };
|
||||||
const setSText = (k) => (val) => { setSecurity((p) => ({ ...p, [k]: val })); setDirty(true); };
|
const setSText = (k) => (e) => { setSecurity((p) => ({ ...p, [k]: e.target.value })); setDirty(true); };
|
||||||
|
|
||||||
const save = () => { setToast(true); setDirty(false); setTimeout(() => setToast(false), 2500); };
|
const save = () => { setToast(true); setDirty(false); };
|
||||||
const discard = () => {
|
const discard = () => {
|
||||||
setGeneral(INITIAL_GENERAL);
|
setGeneral(INITIAL_GENERAL);
|
||||||
setNotify(INITIAL_NOTIFY);
|
setNotify(INITIAL_NOTIFY);
|
||||||
@@ -182,211 +150,211 @@ export default function Settings() {
|
|||||||
const mismatch = security.confirmPassword && security.newPassword !== security.confirmPassword;
|
const mismatch = security.confirmPassword && security.newPassword !== security.confirmPassword;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Settings"
|
title="Settings"
|
||||||
|
breadcrumbs={[{ label: 'Settings' }]}
|
||||||
action={
|
action={
|
||||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
<Stack
|
||||||
{dirty && <Badge variant="warning" label="Unsaved changes" />}
|
direction="row" spacing={1.5} alignItems="center" useFlexGap flexWrap="wrap"
|
||||||
<Button variant="ghost" onClick={discard} label="Discard" />
|
sx={{ width: { xs: '100%', sm: 'auto' }, justifyContent: { xs: 'flex-start', sm: 'flex-end' } }}
|
||||||
<Button variant="primary" icon={<Save size={14} />} onClick={save} label="Save Changes" />
|
>
|
||||||
</div>
|
{dirty && <Chip size="small" label="Unsaved changes" sx={{ bgcolor: 'warning.lighter', color: 'warning.dark', fontWeight: 600 }} />}
|
||||||
|
<Button variant="outlined" onClick={discard} sx={{ flex: { xs: 1, sm: 'none' } }}>Discard</Button>
|
||||||
|
<Button variant="contained" startIcon={<SaveOutlinedIcon />} onClick={save} sx={{ flex: { xs: 1, sm: 'none' } }}>Save Changes</Button>
|
||||||
|
</Stack>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', lg: 'repeat(12, 1fr)', gap: '24px', marginTop: '24px' }} className="responsive-row">
|
<Grid container spacing={2.5}>
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div style={{ gridColumn: 'span 3' }} className="col-span-3">
|
<Grid item xs={12} md={3}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
<Stack spacing={2.5} sx={{ position: { md: 'sticky' }, top: { md: 88 } }}>
|
||||||
<Card style={{ padding: '8px' }}>
|
<Card sx={{ p: 1.5 }}>
|
||||||
<List density="spacious">
|
<Typography variant="overline" sx={{ px: 1, color: 'text.secondary', fontWeight: 700, letterSpacing: 0.6 }}>Preferences</Typography>
|
||||||
{NAV.map((item, i) => (
|
<Stack spacing={0.25} sx={{ mt: 0.5 }}>
|
||||||
<ListItem
|
{NAV.map((item, i) => {
|
||||||
|
const active = tab === i;
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
key={item.label}
|
key={item.label}
|
||||||
label={item.label}
|
direction="row" spacing={1.5} alignItems="center"
|
||||||
description={item.desc}
|
|
||||||
startContent={<item.icon size={16} />}
|
|
||||||
isSelected={tab === i}
|
|
||||||
onClick={() => setTab(i)}
|
onClick={() => setTab(i)}
|
||||||
/>
|
sx={{
|
||||||
))}
|
px: 1.5, py: 1.25, borderRadius: 2, cursor: 'pointer', position: 'relative',
|
||||||
</List>
|
bgcolor: active ? 'primary.lighter' : 'transparent',
|
||||||
</Card>
|
transition: 'background-color .15s',
|
||||||
|
'&:hover': { bgcolor: active ? 'primary.lighter' : 'grey.50' },
|
||||||
<Card style={{ padding: '20px', backgroundColor: 'rgba(192, 18, 39, 0.05)', border: '1px solid rgba(192, 18, 39, 0.1)' }}>
|
'&::before': active ? { content: '""', position: 'absolute', left: 0, top: 9, bottom: 9, width: 3, borderRadius: 3, bgcolor: 'primary.main' } : {}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
||||||
<div style={{ width: '38px', height: '38px', borderRadius: '8px', backgroundColor: '#C01227', color: '#ffffff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<HelpCircle size={16} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Text type="body" weight="bold" color="accent">Need a hand?</Text>
|
|
||||||
<div style={{ marginTop: '2px' }}>
|
|
||||||
<Text type="supporting" color="accent">Our team is available 24/7 for operational support.</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button size="sm" variant="primary" label="Contact support" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div style={{ gridColumn: 'span 9' }} className="col-span-9">
|
|
||||||
{tab === 0 && (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
|
||||||
{/* Org banner */}
|
|
||||||
<Card style={{ padding: '0', overflow: 'hidden' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: '20px',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '24px',
|
|
||||||
background: 'linear-gradient(90deg, rgba(192, 18, 39, 0.05) 0%, #ffffff 75%)',
|
|
||||||
flexWrap: 'wrap'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ width: '64px', height: '64px', borderRadius: '8px', backgroundColor: '#C01227', color: '#ffffff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<Box sx={{ width: 34, height: 34, borderRadius: 1.5, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: active ? 'primary.main' : 'grey.100', color: active ? '#fff' : 'grey.600' }}>
|
||||||
<Building size={32} />
|
<Icon fontSize="small" />
|
||||||
</div>
|
</Box>
|
||||||
<div style={{ flexGrow: 1, minWidth: 0 }}>
|
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: active ? 'primary.main' : 'grey.800' }}>{item.label}</Typography>
|
||||||
<Heading level={4}>{general.orgName}</Heading>
|
<Typography variant="caption" color="text.secondary">{item.desc}</Typography>
|
||||||
<Badge variant="success" icon={<CheckCircle2 size={12} />} label="Verified" />
|
</Box>
|
||||||
</div>
|
<ChevronRightIcon sx={{ fontSize: 18, color: active ? 'primary.main' : 'grey.300' }} />
|
||||||
<Text type="supporting" color="secondary">{general.supportEmail} · {general.contact}</Text>
|
</Stack>
|
||||||
</div>
|
);
|
||||||
<Button variant="secondary" size="sm" label="Change logo" />
|
})}
|
||||||
</div>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Section icon={Sliders} title="Organisation" subtitle="Profile and regional preferences">
|
<Card sx={{ p: 2.5, bgcolor: 'primary.lighter', borderColor: 'primary.100' }}>
|
||||||
|
<Stack spacing={1.25}>
|
||||||
|
<Box sx={{ width: 38, height: 38, borderRadius: 2, bgcolor: 'primary.main', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<HelpOutlineRoundedIcon fontSize="small" />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'primary.dark' }}>Need a hand?</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'primary.dark', opacity: 0.85 }}>Our team is available 24/7 for operational support.</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button size="small" variant="contained" sx={{ alignSelf: 'flex-start' }}>Contact support</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<Grid item xs={12} md={9}>
|
||||||
|
{tab === 0 && (
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
{/* Organisation identity banner */}
|
||||||
|
<Card sx={{ overflow: 'hidden' }}>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', sm: 'row' }} spacing={2.5} alignItems={{ sm: 'center' }}
|
||||||
|
sx={{ p: 3, background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}88 0%, ${theme.palette.background.paper} 75%)` }}
|
||||||
|
>
|
||||||
|
<Avatar variant="rounded" sx={{ width: 64, height: 64, bgcolor: 'primary.main', color: '#fff' }}>
|
||||||
|
<BusinessOutlinedIcon />
|
||||||
|
</Avatar>
|
||||||
|
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700, color: 'grey.800' }}>{general.orgName}</Typography>
|
||||||
|
<Chip size="small" icon={<VerifiedOutlinedIcon sx={{ fontSize: 15, ml: 0.5 }} />} label="Verified" sx={{ fontWeight: 700, bgcolor: 'success.lighter', color: 'success.dark', '& .MuiChip-icon': { color: 'inherit' } }} />
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>{general.supportEmail} · {general.contact}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button variant="outlined" size="small">Change logo</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Section icon={TuneOutlinedIcon} title="Organisation" subtitle="Profile and regional preferences" color="primary">
|
||||||
|
<Stack divider={<Divider />}>
|
||||||
<Row label="Organisation name" description="Shown on invoices and exports">
|
<Row label="Organisation name" description="Shown on invoices and exports">
|
||||||
<TextInput label="Organisation name" isLabelHidden width="100%" value={general.orgName} onChange={setG('orgName')} />
|
<TextField fullWidth size="small" value={general.orgName} onChange={setG('orgName')} />
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="Support email" description="Where customer replies are routed">
|
<Row label="Support email" description="Where customer replies are routed">
|
||||||
<TextInput label="Support email" isLabelHidden width="100%" type="email" value={general.supportEmail} onChange={setG('supportEmail')} />
|
<TextField fullWidth size="small" value={general.supportEmail} onChange={setG('supportEmail')} />
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="Contact number" description="Primary operations line">
|
<Row label="Contact number" description="Primary operations line">
|
||||||
<TextInput label="Contact number" isLabelHidden width="100%" value={general.contact} onChange={setG('contact')} />
|
<TextField fullWidth size="small" value={general.contact} onChange={setG('contact')} />
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="Timezone" description="Used for schedules and reports">
|
<Row label="Timezone" description="Used for schedules and reports">
|
||||||
<div style={{ width: '100%' }}>
|
<TextField select fullWidth size="small" value={general.timezone} onChange={setG('timezone')}>
|
||||||
<Selector label="Timezone" isLabelHidden options={TIMEZONES} value={general.timezone} onChange={(v) => setG('timezone')(v || '')} />
|
{TIMEZONES.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||||
</div>
|
</TextField>
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="Language" description="Console display language">
|
<Row label="Language" description="Console display language">
|
||||||
<div style={{ width: '100%' }}>
|
<TextField select fullWidth size="small" value={general.language} onChange={setG('language')}>
|
||||||
<Selector label="Language" isLabelHidden options={LANGUAGES} value={general.language} onChange={(v) => setG('language')(v || '')} />
|
{LANGUAGES.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||||
</div>
|
</TextField>
|
||||||
</Row>
|
</Row>
|
||||||
|
</Stack>
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 1 && (
|
{tab === 1 && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
<Stack spacing={2.5}>
|
||||||
<Section icon={Bell} title="Notification Preferences" subtitle="Choose what you get alerted about">
|
<Section icon={NotificationsNoneIcon} title="Notification Preferences" subtitle="Choose what you get alerted about" color="primary">
|
||||||
|
<Stack divider={<Divider />}>
|
||||||
{NOTIFY_ROWS.map((row) => (
|
{NOTIFY_ROWS.map((row) => (
|
||||||
<Row key={row.k} label={row.t} description={row.d} controlAlign="end">
|
<Row key={row.k} label={row.t} description={row.d}>
|
||||||
<Switch label={row.t} isLabelHidden value={notify[row.k]} onChange={setN(row.k)} />
|
<Box sx={rightAlign}><Switch checked={notify[row.k]} onChange={setN(row.k)} /></Box>
|
||||||
</Row>
|
</Row>
|
||||||
))}
|
))}
|
||||||
|
</Stack>
|
||||||
</Section>
|
</Section>
|
||||||
<Section icon={Megaphone} title="Delivery Channels" subtitle="How alerts reach your team">
|
<Section icon={CampaignOutlinedIcon} title="Delivery Channels" subtitle="How alerts reach your team" color="primary">
|
||||||
<Row label="Email alerts" description="Send notifications to the support inbox" controlAlign="end">
|
<Stack divider={<Divider />}>
|
||||||
<Switch label="Email alerts" isLabelHidden value={notify.emailAlerts} onChange={setN('emailAlerts')} />
|
<Row label="Email alerts" description="Send notifications to the support inbox">
|
||||||
|
<Box sx={rightAlign}><Switch checked={notify.emailAlerts} onChange={setN('emailAlerts')} /></Box>
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="SMS alerts" description="Send notifications to the registered mobile" controlAlign="end">
|
<Row label="SMS alerts" description="Send notifications to the registered mobile">
|
||||||
<Switch label="SMS alerts" isLabelHidden value={notify.smsAlerts} onChange={setN('smsAlerts')} />
|
<Box sx={rightAlign}><Switch checked={notify.smsAlerts} onChange={setN('smsAlerts')} /></Box>
|
||||||
</Row>
|
</Row>
|
||||||
|
</Stack>
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 2 && (
|
{tab === 2 && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
<Stack spacing={2.5}>
|
||||||
<Section icon={Lock} title="Change Password" subtitle="Use 8+ characters with a mix of letters, numbers & symbols">
|
<Section icon={LockOutlinedIcon} title="Change Password" subtitle="Use 8+ characters with a mix of letters, numbers & symbols" color="primary">
|
||||||
<Row label="Current password" description="Enter your existing password">
|
<Stack divider={<Divider />}>
|
||||||
<PasswordField label="Current password" value={security.currentPassword} onChange={setSText('currentPassword')} />
|
<Row label="Current password" description="Enter your existing password" align="flex-start">
|
||||||
|
<PasswordField label="Current password" value={security.currentPassword} onChange={setSText('currentPassword')} autoComplete="current-password" />
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="New password" description="Choose a strong, unique password">
|
<Row label="New password" description="Choose a strong, unique password" align="flex-start">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', width: '100%' }}>
|
<Box>
|
||||||
<PasswordField label="New password" value={security.newPassword} onChange={setSText('newPassword')} />
|
<PasswordField label="New password" value={security.newPassword} onChange={setSText('newPassword')} autoComplete="new-password" />
|
||||||
{security.newPassword && (
|
{security.newPassword && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginTop: '4px' }}>
|
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mt: 1 }}>
|
||||||
<div style={{ flexGrow: 1, height: '6px', borderRadius: '3px', backgroundColor: '#f1f5f9', overflow: 'hidden' }}>
|
<LinearProgress variant="determinate" value={(pwScore / 4) * 100} color={pwMeta.color} sx={{ flexGrow: 1, height: 6, borderRadius: 3 }} />
|
||||||
<div style={{ height: '100%', backgroundColor: pwMeta.color, width: `${(pwScore / 4) * 100}%` }} />
|
<Typography variant="caption" sx={{ fontWeight: 700, color: `${pwMeta.color}.main`, minWidth: 56 }}>{pwMeta.label}</Typography>
|
||||||
</div>
|
</Stack>
|
||||||
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: pwMeta.color, minWidth: '56px' }}>{pwMeta.label}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="Confirm new password" description="Re-enter the new password">
|
<Row label="Confirm new password" description="Re-enter the new password" align="flex-start">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', width: '100%' }}>
|
<Box>
|
||||||
<PasswordField label="Confirm new password" value={security.confirmPassword} onChange={setSText('confirmPassword')} />
|
<PasswordField label="Confirm new password" value={security.confirmPassword} onChange={setSText('confirmPassword')} autoComplete="new-password" />
|
||||||
{mismatch && <span style={{ fontSize: '0.75rem', color: '#ef4444', display: 'block', marginTop: '4px' }}>Passwords do not match</span>}
|
{mismatch && <Typography variant="caption" color="error.main" sx={{ mt: 0.75, display: 'block' }}>Passwords do not match</Typography>}
|
||||||
</div>
|
</Box>
|
||||||
</Row>
|
</Row>
|
||||||
|
</Stack>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section icon={Shield} title="Two-Factor Authentication" subtitle="Add an extra layer of security to your account">
|
<Section icon={ShieldOutlinedIcon} title="Two-Factor Authentication" subtitle="Add an extra layer of security to your account" color="primary">
|
||||||
<Row label="Authenticator app" description="Require a one-time code at sign-in for extra security" controlAlign="end">
|
<Row label="Authenticator app" description="Require a one-time code at sign-in for extra security">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ justifyContent: { sm: 'flex-end' } }}>
|
||||||
<Badge variant={security.twoFactor ? 'success' : 'neutral'} label={security.twoFactor ? 'Enabled' : 'Disabled'} />
|
<Chip
|
||||||
<Switch
|
size="small"
|
||||||
label="Two-factor authentication"
|
icon={<ShieldOutlinedIcon sx={{ fontSize: 15, ml: 0.5 }} />}
|
||||||
isLabelHidden
|
label={security.twoFactor ? 'Enabled' : 'Disabled'}
|
||||||
value={security.twoFactor}
|
sx={{ fontWeight: 700, bgcolor: security.twoFactor ? 'success.lighter' : 'grey.100', color: security.twoFactor ? 'success.dark' : 'grey.600', '& .MuiChip-icon': { color: 'inherit' } }}
|
||||||
onChange={(v) => { setSecurity((p) => ({ ...p, twoFactor: v })); setDirty(true); }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<Switch checked={security.twoFactor} onChange={(e) => { setSecurity((p) => ({ ...p, twoFactor: e.target.checked })); setDirty(true); }} />
|
||||||
|
</Stack>
|
||||||
</Row>
|
</Row>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section icon={AlertTriangle} title="Danger Zone" subtitle="Irreversible and high-impact actions" danger>
|
<Section icon={WarningAmberRoundedIcon} title="Danger Zone" subtitle="Irreversible and high-impact actions" color="error" danger>
|
||||||
<Row label="Sign out of all sessions" description="End every active session on all devices" controlAlign="end">
|
<Row label="Sign out of all sessions" description="End every active session on all devices">
|
||||||
<Button variant="destructive" onClick={() => { localStorage.removeItem('auth_token'); window.location.href = '/login'; }} label="Sign out everywhere" icon={<LogOut size={14} />} />
|
<Box sx={rightAlign}>
|
||||||
|
<Button variant="outlined" color="error" startIcon={<LogoutOutlinedIcon />} onClick={() => { localStorage.removeItem('auth_token'); window.location.href = '/login'; }}>Sign out everywhere</Button>
|
||||||
|
</Box>
|
||||||
</Row>
|
</Row>
|
||||||
</Section>
|
</Section>
|
||||||
</div>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Grid>
|
||||||
</div>
|
</Grid>
|
||||||
|
|
||||||
{toast && (
|
<Snackbar
|
||||||
<div style={{ position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)', backgroundColor: '#0f172a', color: '#ffffff', padding: '12px 24px', borderRadius: '8px', boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)', zIndex: 3000, display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.875rem' }}>
|
open={toast}
|
||||||
<CheckCircle2 size={16} style={{ color: '#10b981' }} />
|
autoHideDuration={2500}
|
||||||
|
onClose={() => setToast(false)}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
<Alert severity="success" variant="filled" onClose={() => setToast(false)} sx={{ width: '100%' }}>
|
||||||
Settings saved successfully.
|
Settings saved successfully.
|
||||||
</div>
|
</Alert>
|
||||||
)}
|
</Snackbar>
|
||||||
|
</>
|
||||||
<style>{`
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.responsive-row {
|
|
||||||
grid-template-columns: repeat(12, 1fr) !important;
|
|
||||||
}
|
|
||||||
.col-span-3 {
|
|
||||||
grid-column: span 3 !important;
|
|
||||||
}
|
|
||||||
.col-span-9 {
|
|
||||||
grid-column: span 9 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.responsive-row {
|
|
||||||
grid-template-columns: 1fr !important;
|
|
||||||
}
|
|
||||||
.col-span-3, .col-span-9 {
|
|
||||||
grid-column: span 1 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.settings-row:last-child {
|
|
||||||
border-bottom: none !important;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Eye, EyeOff, Mail, Lock } from 'lucide-react';
|
import {
|
||||||
import { Center } from '@astryxdesign/core/Center';
|
Box,
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
Grid,
|
||||||
import { VStack, HStack } from '@astryxdesign/core/Layout';
|
Card,
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
Stack,
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
Typography,
|
||||||
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
TextField,
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
InputAdornment,
|
||||||
import { Link } from '@astryxdesign/core/Link';
|
IconButton,
|
||||||
import { Banner } from '@astryxdesign/core/Banner';
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
FormControlLabel,
|
||||||
|
Link
|
||||||
|
} from '@mui/material';
|
||||||
|
import Visibility from '@mui/icons-material/Visibility';
|
||||||
|
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||||
|
|
||||||
import Logo from '@/components/Logo';
|
import Logo from '@/components/Logo';
|
||||||
import bgImage from '../../assets/mid-mile-approach.jpg';
|
import bgImage from '../../assets/mid-mile-approach.jpg';
|
||||||
|
|
||||||
import { loginAdmin } from '@/utils/apiClient';
|
import { loginAdmin } from '@/utils/apiClient';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
@@ -21,14 +28,10 @@ export default function Login() {
|
|||||||
const [auth, setAuth] = useState('');
|
const [auth, setAuth] = useState('');
|
||||||
const [pwd, setPwd] = useState('');
|
const [pwd, setPwd] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [rememberMe, setRememberMe] = useState(true);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (!auth || !pwd || loading) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
try {
|
||||||
|
setError('');
|
||||||
const data = await loginAdmin(auth, pwd);
|
const data = await loginAdmin(auth, pwd);
|
||||||
localStorage.setItem('logged_in', 'true');
|
localStorage.setItem('logged_in', 'true');
|
||||||
localStorage.setItem('auth_token', data.token);
|
localStorage.setItem('auth_token', data.token);
|
||||||
@@ -36,8 +39,6 @@ export default function Login() {
|
|||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Invalid email or password');
|
setError(err.message || 'Invalid email or password');
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,128 +47,132 @@ export default function Login() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
position: 'relative',
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
backgroundImage: `url(${bgImage})`,
|
backgroundImage: `url(${bgImage})`,
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
fontFamily: 'system-ui, sans-serif'
|
position: 'relative',
|
||||||
}}
|
'&::before': {
|
||||||
>
|
content: '""',
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
background: 'linear-gradient(180deg, rgba(15,23,42,0.6) 0%, rgba(192, 18, 39, 0.8) 100%)'
|
background: 'linear-gradient(180deg, rgba(15,23,42,0.6) 0%, rgba(192, 18, 39, 0.8) 100%)',
|
||||||
|
zIndex: 1
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
|
||||||
<Center axis="both" style={{ position: 'relative', minHeight: '100vh', padding: '16px', boxSizing: 'border-box' }}>
|
|
||||||
<Card
|
<Card
|
||||||
padding={8}
|
sx={{
|
||||||
width="100%"
|
position: 'relative',
|
||||||
maxWidth={440}
|
zIndex: 2,
|
||||||
style={{
|
width: '100%',
|
||||||
background: 'rgba(255, 255, 255, 0.9)',
|
maxWidth: 440,
|
||||||
|
p: { xs: 4, sm: 5 },
|
||||||
|
m: 2,
|
||||||
|
background: 'rgba(255, 255, 255, 0.85)',
|
||||||
backdropFilter: 'blur(24px)',
|
backdropFilter: 'blur(24px)',
|
||||||
WebkitBackdropFilter: 'blur(24px)',
|
WebkitBackdropFilter: 'blur(24px)',
|
||||||
border: '1px solid rgba(255, 255, 255, 0.5)',
|
border: '1px solid rgba(255, 255, 255, 0.5)',
|
||||||
boxShadow: '0 24px 48px rgba(0,0,0,0.2)',
|
boxShadow: '0 24px 48px rgba(0,0,0,0.2)',
|
||||||
boxSizing: 'border-box'
|
borderRadius: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VStack gap={4} hAlign="stretch">
|
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'center' }}><Logo /></Box>
|
||||||
<VStack gap={1} hAlign="center">
|
<Typography variant="h3" sx={{ fontWeight: 800, textAlign: 'center', letterSpacing: '-0.5px', color: '#1e293b' }}>Welcome back</Typography>
|
||||||
<div style={{ marginBottom: '8px' }}>
|
<Typography variant="body1" sx={{ mt: 1, mb: 4, textAlign: 'center', color: '#475569' }}>
|
||||||
<Logo />
|
|
||||||
</div>
|
|
||||||
<Text type="display-1" as="h2">Welcome back</Text>
|
|
||||||
<Text type="body" color="secondary" size="sm">
|
|
||||||
Sign in to your Doormile operations account.
|
Sign in to your Doormile operations account.
|
||||||
</Text>
|
</Typography>
|
||||||
</VStack>
|
|
||||||
|
|
||||||
{error && <Banner status="error" title={error} />}
|
<Stack spacing={2.5}>
|
||||||
|
<Box>
|
||||||
<VStack gap={3}>
|
<Typography variant="subtitle2" sx={{ mb: 0.75, color: '#334155', fontWeight: 600 }}>Email</Typography>
|
||||||
<TextInput
|
<TextField
|
||||||
label="Email"
|
fullWidth
|
||||||
isRequired
|
placeholder="Enter your email address"
|
||||||
size="lg"
|
|
||||||
type="email"
|
|
||||||
placeholder="admin@doormile.com"
|
|
||||||
value={auth}
|
value={auth}
|
||||||
onChange={setAuth}
|
onChange={(e) => setAuth(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
startIcon={<Mail size={16} />}
|
sx={{
|
||||||
hasAutoFocus
|
'& .MuiOutlinedInput-root': {
|
||||||
|
bgcolor: 'rgba(255,255,255,0.6)',
|
||||||
|
borderRadius: 2,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
'&:hover': { bgcolor: 'rgba(255,255,255,0.9)' },
|
||||||
|
'&.Mui-focused': { bgcolor: '#fff', boxShadow: '0 4px 12px rgba(0,0,0,0.05)' }
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
<div style={{ position: 'relative' }}>
|
<Box>
|
||||||
<TextInput
|
<Typography variant="subtitle2" sx={{ mb: 0.75, color: '#334155', fontWeight: 600 }}>Password</Typography>
|
||||||
label="Password"
|
<TextField
|
||||||
isRequired
|
fullWidth
|
||||||
size="lg"
|
|
||||||
type={show ? 'text' : 'password'}
|
type={show ? 'text' : 'password'}
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
value={pwd}
|
value={pwd}
|
||||||
onChange={setPwd}
|
onChange={(e) => setPwd(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
startIcon={<Lock size={16} />}
|
sx={{
|
||||||
/>
|
'& .MuiOutlinedInput-root': {
|
||||||
<button
|
bgcolor: 'rgba(255,255,255,0.6)',
|
||||||
type="button"
|
borderRadius: 2,
|
||||||
onClick={() => setShow((s) => !s)}
|
transition: 'all 0.2s ease',
|
||||||
aria-label={show ? 'Hide password' : 'Show password'}
|
'&:hover': { bgcolor: 'rgba(255,255,255,0.9)' },
|
||||||
style={{
|
'&.Mui-focused': { bgcolor: '#fff', boxShadow: '0 4px 12px rgba(0,0,0,0.05)' }
|
||||||
position: 'absolute',
|
}
|
||||||
right: '10px',
|
}}
|
||||||
bottom: 0,
|
InputProps={{
|
||||||
height: '36px',
|
endAdornment: (
|
||||||
display: 'flex',
|
<InputAdornment position="end">
|
||||||
alignItems: 'center',
|
<IconButton onClick={() => setShow((s) => !s)} edge="end" size="small">
|
||||||
justifyContent: 'center',
|
{show ? <VisibilityOff fontSize="small" sx={{ color: '#94a3b8' }} /> : <Visibility fontSize="small" sx={{ color: '#94a3b8' }} />}
|
||||||
background: 'none',
|
</IconButton>
|
||||||
border: 'none',
|
</InputAdornment>
|
||||||
cursor: 'pointer',
|
)
|
||||||
color: '#64748b',
|
|
||||||
padding: 0,
|
|
||||||
zIndex: 10
|
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<HStack hAlign="between" vAlign="center">
|
|
||||||
<CheckboxInput
|
|
||||||
label="Remember me"
|
|
||||||
value={rememberMe}
|
|
||||||
onChange={setRememberMe}
|
|
||||||
/>
|
/>
|
||||||
<Link href="#" size="sm" color="secondary">Forgot password?</Link>
|
</Box>
|
||||||
</HStack>
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||||
|
<FormControlLabel control={<Checkbox defaultChecked size="small" sx={{ color: '#cbd5e1', '&.Mui-checked': { color: 'primary.main' } }} />} label={<Typography variant="body2" sx={{ color: '#475569', fontWeight: 500 }}>Remember me</Typography>} />
|
||||||
|
<Link href="#" underline="hover" variant="body2" color="primary" sx={{ fontWeight: 600 }}>Forgot password?</Link>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{error && <Typography variant="body2" color="error" textAlign="center" sx={{ fontWeight: 600 }}>{error}</Typography>}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
label="Sign In"
|
fullWidth
|
||||||
variant="primary"
|
size="large"
|
||||||
size="lg"
|
variant="contained"
|
||||||
onClick={handleLogin}
|
onClick={handleLogin}
|
||||||
isLoading={loading}
|
sx={{
|
||||||
isDisabled={!auth || !pwd}
|
mt: 2,
|
||||||
/>
|
py: 1.5,
|
||||||
</VStack>
|
borderRadius: 2,
|
||||||
|
fontSize: '1.05rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'none',
|
||||||
|
boxShadow: '0 8px 16px rgba(192, 18, 39, 0.25)',
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: '0 12px 20px rgba(192, 18, 39, 0.35)',
|
||||||
|
},
|
||||||
|
transition: 'box-shadow 0.2s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
</Center>
|
|
||||||
|
|
||||||
<div style={{ position: 'absolute', bottom: '20px', left: 0, right: 0, textAlign: 'center', zIndex: 2 }}>
|
<Box sx={{ position: 'absolute', bottom: 20, zIndex: 2 }}>
|
||||||
<Text type="supporting" style={{ color: 'rgba(255, 255, 255, 0.8)' }}>
|
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.8rem' }}>
|
||||||
© {new Date().getFullYear()} Doormile Technologies. All rights reserved.
|
© {new Date().getFullYear()} Doormile Technologies. All rights reserved.
|
||||||
</Text>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,15 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Plus, Search, Trash2 } from 'lucide-react';
|
import {
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
Card, Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
Typography, TextField, Button, Dialog, DialogTitle, DialogContent, DialogActions,
|
||||||
import { Table, proportional, pixel } from '@astryxdesign/core/Table';
|
Grid, IconButton, CircularProgress, Chip, MenuItem, InputAdornment, alpha, Autocomplete, Checkbox, FormControlLabel, Tabs, Tab, Snackbar, Alert
|
||||||
import { Badge } from '@astryxdesign/core/Badge';
|
} from '@mui/material';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import { FormLayout } from '@astryxdesign/core/FormLayout';
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||||
import { Field } from '@astryxdesign/core/Field';
|
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
||||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
|
||||||
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
|
||||||
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
import EmptyState from '@/components/EmptyState';
|
|
||||||
import StatusChip from '@/components/StatusChip';
|
|
||||||
import TabLabelCount from '@/components/TabLabelCount';
|
|
||||||
import { fetchClients } from '@/utils/apiClient';
|
import { fetchClients } from '@/utils/apiClient';
|
||||||
import ClientFormDialog from '../tenants/ClientFormDialog';
|
import ClientFormDialog from '../tenants/ClientFormDialog';
|
||||||
|
|
||||||
@@ -124,13 +116,11 @@ function BookingFormDialog({ open, onClose, onSave, clients, surveys }) {
|
|||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const set = (field) => (val) => {
|
const handleChange = (field) => (e) => setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
||||||
setFormData(prev => ({ ...prev, [field]: val }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleParcelChange = (index, field) => (val) => {
|
const handleParcelChange = (index, field) => (e) => {
|
||||||
const newParcels = [...formData.parcels];
|
const newParcels = [...formData.parcels];
|
||||||
newParcels[index] = { ...newParcels[index], [field]: val };
|
newParcels[index] = { ...newParcels[index], [field]: e.target.value };
|
||||||
setFormData(prev => ({ ...prev, parcels: newParcels }));
|
setFormData(prev => ({ ...prev, parcels: newParcels }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -206,219 +196,214 @@ function BookingFormDialog({ open, onClose, onSave, clients, surveys }) {
|
|||||||
|
|
||||||
const uniqueProviders = Array.from(new Set(surveys.map(s => s.company).filter(Boolean)));
|
const uniqueProviders = Array.from(new Set(surveys.map(s => s.company).filter(Boolean)));
|
||||||
const providerLocations = surveys.filter(s => s.company === formData.providercompany).map(s => s.area || s.address).filter(Boolean);
|
const providerLocations = surveys.filter(s => s.company === formData.providercompany).map(s => s.area || s.address).filter(Boolean);
|
||||||
const num = (v) => (v === '' || v == null ? undefined : Number(v));
|
|
||||||
const setNum = (field) => (v) => set(field)(v == null ? '' : v);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog isOpen={open} onOpenChange={(o) => { if (!o) onClose(); }} width={800} maxHeight="90dvh" purpose="form">
|
<Snackbar
|
||||||
<Layout
|
open={!!error}
|
||||||
header={<DialogHeader title="Create New Booking" onOpenChange={(o) => { if (!o) onClose(); }} />}
|
autoHideDuration={6000}
|
||||||
content={
|
onClose={() => setError(null)}
|
||||||
<LayoutContent>
|
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
<FormLayout>
|
sx={{ zIndex: 9999 }}
|
||||||
{error && (
|
>
|
||||||
<div style={{ padding: '12px 16px', backgroundColor: '#fef2f2', border: '1px solid #fca5a5', borderRadius: '8px', color: '#b91c1c', fontSize: '0.875rem' }}>
|
<Alert onClose={() => setError(null)} severity="error" variant="filled" sx={{ width: '100%', boxShadow: 3 }}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</Alert>
|
||||||
)}
|
</Snackbar>
|
||||||
|
|
||||||
{/* Customer Details */}
|
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="md" fullWidth>
|
||||||
<div>
|
<DialogTitle sx={{ fontWeight: 800 }}>Create New Booking</DialogTitle>
|
||||||
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#C01227', fontSize: '0.9rem', textTransform: 'uppercase' }}>Customer Details</div>
|
<DialogContent dividers>
|
||||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'primary.main' }}>Customer Details</Typography>
|
||||||
<div style={{ flexGrow: 1, minWidth: '200px' }}>
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
<Field label="Select Client" inputID="booking-client-input">
|
<Grid item xs={12}>
|
||||||
<input
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
id="booking-client-input"
|
<Autocomplete
|
||||||
list="booking-clients"
|
options={clients}
|
||||||
onChange={(e) => {
|
sx={{ flexGrow: 1 }}
|
||||||
const selected = clients.find(c => `${c.first_name || ''} ${c.last_name || ''} - ${c.phone || ''}` === e.target.value);
|
getOptionLabel={(option) => {
|
||||||
if (selected) {
|
if (typeof option === 'string') return option;
|
||||||
|
return `${option.first_name || ''} ${option.last_name || ''} - ${option.phone || ''}`;
|
||||||
|
}}
|
||||||
|
onChange={(e, newValue) => {
|
||||||
|
if (newValue && typeof newValue === 'object') {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
customer_name: `${selected.first_name || ''} ${selected.last_name || ''}`.trim(),
|
customer_name: `${newValue.first_name || ''} ${newValue.last_name || ''}`.trim(),
|
||||||
customer_phone: selected.phone || ''
|
customer_phone: newValue.phone || ''
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
setFormData(prev => ({ ...prev, customer_name: '', customer_phone: '' }));
|
setFormData(prev => ({ ...prev, customer_name: '', customer_phone: '' }));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="Select Existing Client" size="small" />
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<datalist id="booking-clients">
|
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setClientDialogOpen(true)} sx={{ whiteSpace: 'nowrap' }}>
|
||||||
{clients.map((c) => (
|
New Client
|
||||||
<option key={c.id} value={`${c.first_name || ''} ${c.last_name || ''} - ${c.phone || ''}`} />
|
</Button>
|
||||||
))}
|
</Box>
|
||||||
</datalist>
|
</Grid>
|
||||||
</Field>
|
</Grid>
|
||||||
</div>
|
|
||||||
<Button variant="secondary" icon={<Plus size={14} />} onClick={() => setClientDialogOpen(true)} label="New Client" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Assigned Provider */}
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'primary.main' }}>Assigned Provider (From Survey)</Typography>
|
||||||
<div>
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#C01227', fontSize: '0.9rem', textTransform: 'uppercase' }}>Assigned Provider (From Survey)</div>
|
<Grid item xs={12} sm={6}>
|
||||||
<FormLayout direction="horizontal">
|
<Autocomplete
|
||||||
<Selector
|
|
||||||
label="Provider Name" placeholder="Select Provider" hasClear
|
|
||||||
options={uniqueProviders}
|
options={uniqueProviders}
|
||||||
value={formData.providercompany}
|
value={formData.providercompany}
|
||||||
onChange={(v) => setFormData(prev => ({ ...prev, providercompany: v ?? '', providerlocation: '' }))}
|
onChange={(e, val) => setFormData(prev => ({ ...prev, providercompany: val || '', providerlocation: '' }))}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Provider Name" size="small" />}
|
||||||
/>
|
/>
|
||||||
<Selector
|
</Grid>
|
||||||
label="Provider Location / Sub Hub" placeholder="Select Location" hasClear
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Autocomplete
|
||||||
options={providerLocations}
|
options={providerLocations}
|
||||||
value={formData.providerlocation}
|
value={formData.providerlocation}
|
||||||
onChange={(v) => set('providerlocation')(v ?? '')}
|
onChange={(e, val) => setFormData(prev => ({ ...prev, providerlocation: val || '' }))}
|
||||||
isDisabled={!formData.providercompany}
|
renderInput={(params) => <TextField {...params} label="Provider Location / Sub Hub" size="small" />}
|
||||||
|
disabled={!formData.providercompany}
|
||||||
/>
|
/>
|
||||||
</FormLayout>
|
</Grid>
|
||||||
</div>
|
</Grid>
|
||||||
|
|
||||||
{/* Pickup Location */}
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'primary.main' }}>Pickup Location</Typography>
|
||||||
<div>
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#C01227', fontSize: '0.9rem', textTransform: 'uppercase' }}>Pickup Location</div>
|
<Grid item xs={12} sm={8}>
|
||||||
<FormLayout direction="horizontal">
|
<TextField fullWidth label="Pickup Address *" value={formData.pickupaddress} onChange={handleChange('pickupaddress')} size="small" />
|
||||||
<TextInput label="Pickup Address" isRequired value={formData.pickupaddress} onChange={set('pickupaddress')} />
|
</Grid>
|
||||||
<TextInput label="Pickup Pincode" isRequired value={formData.pickuppincode} onChange={set('pickuppincode')} />
|
<Grid item xs={12} sm={4}>
|
||||||
</FormLayout>
|
<TextField fullWidth label="Pickup Pincode *" value={formData.pickuppincode} onChange={handleChange('pickuppincode')} size="small" />
|
||||||
</div>
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
{/* Delivery Destination */}
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'primary.main' }}>Delivery Destination</Typography>
|
||||||
<div>
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#C01227', fontSize: '0.9rem', textTransform: 'uppercase' }}>Delivery Destination</div>
|
<Grid item xs={12} sm={12}>
|
||||||
<FormLayout>
|
<TextField fullWidth label="Delivery Address" value={formData.deliveryaddress} onChange={handleChange('deliveryaddress')} size="small" />
|
||||||
<TextInput label="Delivery Address" value={formData.deliveryaddress} onChange={set('deliveryaddress')} />
|
</Grid>
|
||||||
<FormLayout direction="horizontal">
|
<Grid item xs={12} sm={8}>
|
||||||
<Field label="City" inputID="delivery-city-input">
|
<Autocomplete
|
||||||
<input id="delivery-city-input" list="delivery-cities" value={formData.deliverycity} onChange={(e) => set('deliverycity')(e.target.value)} />
|
freeSolo
|
||||||
<datalist id="delivery-cities">
|
options={CITIES}
|
||||||
{CITIES.map((c) => <option key={c} value={c} />)}
|
value={formData.deliverycity}
|
||||||
</datalist>
|
onChange={(e, val) => setFormData(prev => ({ ...prev, deliverycity: val || '' }))}
|
||||||
</Field>
|
onInputChange={(e, val) => setFormData(prev => ({ ...prev, deliverycity: val || '' }))}
|
||||||
<TextInput label="Delivery Pincode" value={formData.deliverypincode} onChange={set('deliverypincode')} />
|
renderInput={(params) => <TextField {...params} label="Search City" size="small" />}
|
||||||
</FormLayout>
|
/>
|
||||||
</FormLayout>
|
</Grid>
|
||||||
</div>
|
<Grid item xs={12} sm={4}>
|
||||||
|
<TextField fullWidth label="Delivery Pincode" value={formData.deliverypincode} onChange={handleChange('deliverypincode')} size="small" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
{/* Parcel Details */}
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||||
<div>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'primary.main' }}>Parcel Details</Typography>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
<Button size="small" startIcon={<AddIcon />} onClick={addParcel}>Add Parcel</Button>
|
||||||
<div style={{ fontWeight: 700, color: '#C01227', fontSize: '0.9rem', textTransform: 'uppercase' }}>Parcel Details</div>
|
</Box>
|
||||||
<Button variant="secondary" size="sm" icon={<Plus size={12} />} onClick={addParcel} label="Add Parcel" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.parcels.map((parcel, idx) => (
|
{formData.parcels.map((parcel, idx) => (
|
||||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', marginBottom: '12px', flexWrap: 'wrap' }} key={idx}>
|
<Grid container spacing={1.5} key={idx} sx={{ mb: 2, alignItems: 'center' }}>
|
||||||
<div style={{ flex: '1 1 150px' }}>
|
<Grid item xs={12} sm={3}>
|
||||||
<TextInput label="Category" isLabelHidden placeholder="Category (e.g. Box)" value={parcel.itemcategory} onChange={handleParcelChange(idx, 'itemcategory')} />
|
<TextField fullWidth label="Category (e.g. Box)" value={parcel.itemcategory} onChange={handleParcelChange(idx, 'itemcategory')} size="small" />
|
||||||
</div>
|
</Grid>
|
||||||
<div style={{ width: '90px' }}>
|
<Grid item xs={6} sm={2}>
|
||||||
<NumberInput label="Weight (kg)" isLabelHidden placeholder="Wt (kg)" hasClear value={num(parcel.weight)} onChange={(v) => handleParcelChange(idx, 'weight')(v == null ? '' : v)} />
|
<TextField fullWidth label="Weight (kg)" type="number" value={parcel.weight} onChange={handleParcelChange(idx, 'weight')} size="small" />
|
||||||
</div>
|
</Grid>
|
||||||
<div style={{ width: '90px' }}>
|
<Grid item xs={6} sm={2}>
|
||||||
<NumberInput label="Length (cm)" isLabelHidden placeholder="L (cm)" hasClear value={num(parcel.length)} onChange={(v) => handleParcelChange(idx, 'length')(v == null ? '' : v)} />
|
<TextField fullWidth label="Length (cm)" type="number" value={parcel.length} onChange={handleParcelChange(idx, 'length')} size="small" />
|
||||||
</div>
|
</Grid>
|
||||||
<div style={{ width: '90px' }}>
|
<Grid item xs={6} sm={2}>
|
||||||
<NumberInput label="Width (cm)" isLabelHidden placeholder="W (cm)" hasClear value={num(parcel.width)} onChange={(v) => handleParcelChange(idx, 'width')(v == null ? '' : v)} />
|
<TextField fullWidth label="Width (cm)" type="number" value={parcel.width} onChange={handleParcelChange(idx, 'width')} size="small" />
|
||||||
</div>
|
</Grid>
|
||||||
<div style={{ width: '90px' }}>
|
<Grid item xs={4} sm={2}>
|
||||||
<NumberInput label="Height (cm)" isLabelHidden placeholder="H (cm)" hasClear value={num(parcel.height)} onChange={(v) => handleParcelChange(idx, 'height')(v == null ? '' : v)} />
|
<TextField fullWidth label="Height (cm)" type="number" value={parcel.height} onChange={handleParcelChange(idx, 'height')} size="small" />
|
||||||
</div>
|
</Grid>
|
||||||
<button
|
<Grid item xs={2} sm={1}>
|
||||||
onClick={() => removeParcel(idx)}
|
<IconButton color="error" onClick={() => removeParcel(idx)} disabled={formData.parcels.length === 1}>
|
||||||
disabled={formData.parcels.length === 1}
|
<DeleteOutlineIcon />
|
||||||
style={{
|
</IconButton>
|
||||||
background: 'none',
|
</Grid>
|
||||||
border: 'none',
|
</Grid>
|
||||||
cursor: 'pointer',
|
|
||||||
color: '#dc2626',
|
|
||||||
display: 'flex',
|
|
||||||
padding: '9px',
|
|
||||||
borderRadius: '4px'
|
|
||||||
}}
|
|
||||||
className="action-btn"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Price & Insurance */}
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mt: 2, mb: 1 }}>
|
||||||
<div>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'primary.main' }}>Price & Insurance</Typography>
|
||||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', marginBottom: '12px', flexWrap: 'wrap' }}>
|
<Button size="small" variant="outlined" onClick={handleCheckPrice}>Check Price Estimate</Button>
|
||||||
<div style={{ fontWeight: 700, color: '#C01227', fontSize: '0.9rem', textTransform: 'uppercase' }}>Price & Insurance</div>
|
</Box>
|
||||||
<Button variant="secondary" size="sm" onClick={handleCheckPrice} label="Check Price Estimate" />
|
|
||||||
</div>
|
|
||||||
{quoteInfo && (
|
{quoteInfo && (
|
||||||
<div style={{ padding: '12px', borderLeft: '4px solid #3b82f6', backgroundColor: '#eff6ff', fontSize: '0.875rem', marginBottom: '16px', color: '#1e3a8a' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, px: 1, borderLeft: '4px solid #3b82f6', bgcolor: '#eff6ff', py: 1 }}>
|
||||||
Estimated Base Price: <strong>₹{quoteInfo.basequote?.toFixed(2)}</strong> (Chargeable Weight: {quoteInfo.chargeableweight} kg)
|
Estimated Base Price: <strong>₹{quoteInfo.basequote?.toFixed(2)}</strong> (Chargeable Weight: {quoteInfo.chargeableweight} kg)
|
||||||
</div>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<FormLayout direction="horizontal">
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
<NumberInput label="Final Price (₹)" hasClear value={num(formData.finalprice)} onChange={setNum('finalprice')} />
|
<Grid item xs={12} sm={3}>
|
||||||
<div>
|
<TextField fullWidth label="Final Price (₹)" type="number" value={formData.finalprice} onChange={handleChange('finalprice')} size="small" />
|
||||||
<CheckboxInput
|
</Grid>
|
||||||
label="Needs Insurance? (1%)"
|
<Grid item xs={12} sm={9}>
|
||||||
value={formData.needsinsurance}
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
onChange={(checked) => {
|
<FormControlLabel
|
||||||
|
control={<Checkbox checked={formData.needsinsurance} onChange={(e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
needsinsurance: checked,
|
needsinsurance: checked,
|
||||||
insuranceamount: checked && prev.declaredvalue ? (parseFloat(prev.declaredvalue) * 0.01).toFixed(2) : prev.insuranceamount
|
insuranceamount: checked && prev.declaredvalue ? (parseFloat(prev.declaredvalue) * 0.01).toFixed(2) : prev.insuranceamount
|
||||||
}));
|
}));
|
||||||
}}
|
}} />}
|
||||||
|
label="Needs Insurance? (1%)"
|
||||||
|
sx={{ whiteSpace: 'nowrap' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
<TextField
|
||||||
<NumberInput
|
label="Declared Value (₹)"
|
||||||
label="Declared Value" hasClear value={num(formData.declaredvalue)}
|
type="number"
|
||||||
onChange={(v) => {
|
size="small"
|
||||||
|
sx={{ width: 150 }}
|
||||||
|
value={formData.declaredvalue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
declaredvalue: v == null ? '' : v,
|
declaredvalue: val,
|
||||||
insuranceamount: prev.needsinsurance && v != null ? (v * 0.01).toFixed(2) : prev.insuranceamount
|
insuranceamount: prev.needsinsurance && val ? (parseFloat(val) * 0.01).toFixed(2) : prev.insuranceamount
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NumberInput label="Insurance Amount" hasClear value={num(formData.insuranceamount)} onChange={setNum('insuranceamount')} />
|
<TextField
|
||||||
</FormLayout>
|
label="Insurance Amount (₹)"
|
||||||
</div>
|
type="number"
|
||||||
|
size="small"
|
||||||
|
sx={{ width: 150 }}
|
||||||
|
value={formData.insuranceamount}
|
||||||
|
onChange={handleChange('insuranceamount')}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
{/* Additional info */}
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'primary.main' }}>Additional Information</Typography>
|
||||||
<div>
|
<Grid container spacing={2}>
|
||||||
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#C01227', fontSize: '0.9rem', textTransform: 'uppercase' }}>Additional Information</div>
|
<Grid item xs={12} sm={6}>
|
||||||
<FormLayout direction="horizontal">
|
<TextField select fullWidth label="Select Speed" value={formData.service_option} onChange={handleChange('service_option')} size="small">
|
||||||
<Selector
|
<MenuItem value="Normal">Normal</MenuItem>
|
||||||
label="Select Speed"
|
<MenuItem value="Fast">Fast Express</MenuItem>
|
||||||
options={[{ value: 'Normal', label: 'Normal' }, { value: 'Fast', label: 'Fast Express' }]}
|
</TextField>
|
||||||
value={formData.service_option}
|
</Grid>
|
||||||
onChange={set('service_option')}
|
<Grid item xs={12} sm={6}>
|
||||||
/>
|
<TextField fullWidth label="Notes / Remarks" value={formData.notes} onChange={handleChange('notes')} size="small" />
|
||||||
<TextInput label="Notes / Remarks" value={formData.notes} onChange={set('notes')} />
|
</Grid>
|
||||||
</FormLayout>
|
</Grid>
|
||||||
</div>
|
|
||||||
</FormLayout>
|
</DialogContent>
|
||||||
</LayoutContent>
|
<DialogActions sx={{ p: 2 }}>
|
||||||
}
|
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||||
footer={
|
<Button variant="contained" onClick={handleSave} disabled={saving || !formData.customer_phone} sx={{ bgcolor: 'primary.main', '&:hover': { bgcolor: 'primary.dark' } }}>
|
||||||
<LayoutFooter hasDivider>
|
{saving ? 'Creating...' : 'Create Booking'}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
</Button>
|
||||||
<Button label="Cancel" variant="ghost" onClick={onClose} isDisabled={saving} />
|
</DialogActions>
|
||||||
<Button
|
|
||||||
label={saving ? 'Creating...' : 'Create Booking'}
|
|
||||||
variant="primary"
|
|
||||||
onClick={handleSave}
|
|
||||||
isDisabled={saving || !formData.customer_phone}
|
|
||||||
isLoading={saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</LayoutFooter>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
{/* Embedded Client Registration Form */}
|
||||||
{clientDialogOpen && (
|
{clientDialogOpen && (
|
||||||
<ClientFormDialog
|
<ClientFormDialog
|
||||||
open={clientDialogOpen}
|
open={clientDialogOpen}
|
||||||
@@ -426,7 +411,7 @@ function BookingFormDialog({ open, onClose, onSave, clients, surveys }) {
|
|||||||
onClose={() => setClientDialogOpen(false)}
|
onClose={() => setClientDialogOpen(false)}
|
||||||
onSaved={() => {
|
onSaved={() => {
|
||||||
setClientDialogOpen(false);
|
setClientDialogOpen(false);
|
||||||
onSave();
|
onSave(); // Refetch clients so they appear in dropdown
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -442,8 +427,6 @@ export default function Bookings() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [sourceFilter, setSourceFilter] = useState('All');
|
const [sourceFilter, setSourceFilter] = useState('All');
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const [rpp, setRpp] = useState(10);
|
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -476,177 +459,78 @@ export default function Bookings() {
|
|||||||
return matchesSearch && matchesSource;
|
return matchesSearch && matchesSource;
|
||||||
});
|
});
|
||||||
|
|
||||||
const pagedBookings = displayBookings.slice(page * rpp, page * rpp + rpp);
|
|
||||||
|
|
||||||
const bookingColumns = useMemo(() => [
|
|
||||||
{
|
|
||||||
key: 'bookingno',
|
|
||||||
header: <div style={{ paddingLeft: '12px' }}>Booking No</div>,
|
|
||||||
width: proportional(1.5),
|
|
||||||
renderCell: (b) => (
|
|
||||||
<div style={{ paddingLeft: '12px' }}>
|
|
||||||
<Text type="body" weight="semibold">{b.bookingno}</Text>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'deliverycity',
|
|
||||||
header: 'Delivery City',
|
|
||||||
width: proportional(1),
|
|
||||||
renderCell: (b) => {
|
|
||||||
const city = b.deliverycity || b.deliverypincode;
|
|
||||||
return city
|
|
||||||
? <Text type="body">{city}</Text>
|
|
||||||
: <Text type="supporting" color="disabled">N/A</Text>;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'providercompany',
|
|
||||||
header: 'Provider',
|
|
||||||
width: proportional(1),
|
|
||||||
renderCell: (b) => b.providercompany
|
|
||||||
? <Badge variant="neutral" label={b.providercompany} />
|
|
||||||
: <Text type="supporting" color="disabled">N/A</Text>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bookingsource',
|
|
||||||
header: 'Source',
|
|
||||||
width: pixel(100),
|
|
||||||
renderCell: (b) => (
|
|
||||||
<Badge
|
|
||||||
variant={b.bookingsource === 'CRM_Console' ? 'neutral' : 'red'}
|
|
||||||
label={b.bookingsource === 'CRM_Console' ? 'CRM' : 'App'}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'price',
|
|
||||||
header: 'Price',
|
|
||||||
width: pixel(120),
|
|
||||||
renderCell: (b) => (
|
|
||||||
<Text type="body" weight="semibold" hasTabularNumbers>
|
|
||||||
{b.serviceoptions && b.serviceoptions.length > 0 ? `₹${b.serviceoptions[0].estimatedprice}` : 'N/A'}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
header: 'Status',
|
|
||||||
width: pixel(150),
|
|
||||||
renderCell: (b) => <StatusChip status={b.status} />
|
|
||||||
}
|
|
||||||
], []);
|
|
||||||
|
|
||||||
const sourceCounts = useMemo(() => {
|
|
||||||
const counts = { All: bookings.length, Customer_App: 0, CRM_Console: 0 };
|
|
||||||
bookings.forEach((b) => {
|
|
||||||
if (b.bookingsource === 'CRM_Console') counts.CRM_Console += 1;
|
|
||||||
else counts.Customer_App += 1;
|
|
||||||
});
|
|
||||||
return counts;
|
|
||||||
}, [bookings]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
<Box sx={{ p: 3, bgcolor: '#f8fafc', minHeight: '80vh' }}>
|
||||||
<PageHeader
|
<PageHeader title="Bookings Management" breadcrumbs={[{ label: 'Bookings' }]} />
|
||||||
title="Bookings Management"
|
|
||||||
action={
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'nowrap' }}>
|
|
||||||
<div style={{ flex: '0 1 320px', minWidth: '200px' }}>
|
|
||||||
<TextInput
|
|
||||||
label="Search bookings"
|
|
||||||
isLabelHidden
|
|
||||||
placeholder="Search bookings…"
|
|
||||||
startIcon={<Search size={16} />}
|
|
||||||
hasClear
|
|
||||||
value={search}
|
|
||||||
onChange={(v) => { setSearch(v); setPage(0); }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button variant="primary" icon={<Plus size={14} />} onClick={() => setFormOpen(true)} label="New Booking" style={{ flexShrink: 0 }} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card style={{ marginTop: '24px', padding: '0', borderRadius: '16px', border: '1px solid rgba(5, 54, 89, 0.08)', backgroundColor: '#ffffff', overflow: 'hidden', boxShadow: '0 10px 30px -5px rgba(0,0,0,0.02), 0 4px 12px -3px rgba(0,0,0,0.02)' }}>
|
<Card sx={{ mt: 3, p: 3, borderRadius: 4, boxShadow: '0 12px 24px -4px rgba(0,0,0,0.05)' }}>
|
||||||
{/* Tab filters */}
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
|
||||||
<div style={{ display: 'flex', padding: '0 24px', borderBottom: '1px solid rgba(5, 54, 89, 0.06)', gap: '24px', overflowX: 'auto' }}>
|
<TextField
|
||||||
{[
|
placeholder="Search bookings..."
|
||||||
{ label: 'All Bookings', value: 'All' },
|
value={search}
|
||||||
{ label: 'App Bookings', value: 'Customer_App' },
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
{ label: 'CRM Bookings', value: 'CRM_Console' }
|
size="small"
|
||||||
].map((tab) => (
|
InputProps={{
|
||||||
<div
|
startAdornment: <InputAdornment position="start"><SearchIcon /></InputAdornment>,
|
||||||
key={tab.value}
|
sx: { borderRadius: 3, bgcolor: alpha('#e2e8f0', 0.4) }
|
||||||
onClick={() => { setSourceFilter(tab.value); setPage(0); }}
|
|
||||||
style={{
|
|
||||||
padding: '16px 0',
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderBottom: sourceFilter === tab.value ? '2px solid #C01227' : '2px solid transparent',
|
|
||||||
color: sourceFilter === tab.value ? '#C01227' : '#64748b',
|
|
||||||
fontWeight: sourceFilter === tab.value ? 600 : 500,
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
transition: 'all 0.2s'
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<TabLabelCount label={tab.label} count={sourceCounts[tab.value] || 0} active={sourceFilter === tab.value} />
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setFormOpen(true)} sx={{ borderRadius: 2 }}>
|
||||||
</div>
|
New Booking
|
||||||
))}
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||||
|
<Tabs value={sourceFilter} onChange={(e, val) => setSourceFilter(val)} aria-label="booking source filter tabs">
|
||||||
|
<Tab label="All Bookings" value="All" />
|
||||||
|
<Tab label="App Bookings" value="Customer_App" />
|
||||||
|
<Tab label="CRM Bookings" value="CRM_Console" />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px 0' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>
|
||||||
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #C01227', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
|
||||||
</div>
|
|
||||||
) : displayBookings.length === 0 ? (
|
|
||||||
<EmptyState title="No bookings found" caption="Try a different filter or search term, or create a new booking." />
|
|
||||||
) : (
|
) : (
|
||||||
<div style={{ paddingTop: '12px', paddingBottom: '12px' }}>
|
<TableContainer>
|
||||||
<Table
|
<Table>
|
||||||
data={pagedBookings}
|
<TableHead>
|
||||||
columns={bookingColumns}
|
<TableRow sx={{ bgcolor: alpha('#f1f5f9', 0.6) }}>
|
||||||
idKey="bookingid"
|
<TableCell sx={{ fontWeight: 700 }}>Booking No</TableCell>
|
||||||
density="balanced"
|
<TableCell sx={{ fontWeight: 700 }}>Delivery City</TableCell>
|
||||||
dividers="rows"
|
<TableCell sx={{ fontWeight: 700 }}>Provider</TableCell>
|
||||||
hasHover
|
<TableCell sx={{ fontWeight: 700 }}>Source</TableCell>
|
||||||
isStriped
|
<TableCell sx={{ fontWeight: 700 }}>Price</TableCell>
|
||||||
textOverflow="truncate"
|
<TableCell sx={{ fontWeight: 700 }}>Status</TableCell>
|
||||||
/>
|
</TableRow>
|
||||||
</div>
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{displayBookings.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={6} align="center" sx={{ py: 4 }}>No bookings found.</TableCell></TableRow>
|
||||||
|
) : (
|
||||||
|
displayBookings.map(b => (
|
||||||
|
<TableRow key={b.bookingid} hover>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>{b.bookingno}</TableCell>
|
||||||
|
<TableCell>{b.deliverycity || b.deliverypincode || 'N/A'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{b.providercompany ? (
|
||||||
|
<Chip label={b.providercompany} size="small" sx={{ mr: 1 }} />
|
||||||
|
) : 'N/A'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={b.bookingsource === 'CRM_Console' ? 'CRM' : 'App'} size="small" variant="outlined" color={b.bookingsource === 'CRM_Console' ? 'secondary' : 'primary'} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{b.serviceoptions && b.serviceoptions.length > 0 ? `₹${b.serviceoptions[0].estimatedprice}` : 'N/A'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={b.status} size="small" color={b.status === 'Pending_Pickup' ? 'warning' : 'primary'} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
|
</TableBody>
|
||||||
{!loading && displayBookings.length > 0 && (
|
</Table>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', padding: '12px 24px', borderTop: '1px solid #e2e8f0', gap: '20px', flexWrap: 'wrap' }}>
|
</TableContainer>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.875rem', color: '#475569' }}>
|
|
||||||
<span>Rows per page:</span>
|
|
||||||
<select
|
|
||||||
value={rpp}
|
|
||||||
onChange={(e) => { setRpp(Number(e.target.value)); setPage(0); }}
|
|
||||||
className="rpp-select"
|
|
||||||
>
|
|
||||||
{[5, 10, 25].map((opt) => <option key={opt} value={opt}>{opt}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: '0.875rem', color: '#475569' }}>
|
|
||||||
{displayBookings.length === 0 ? '0-0' : `${page * rpp + 1}-${Math.min((page + 1) * rpp, displayBookings.length)}`} of {displayBookings.length}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<Button
|
|
||||||
label="Prev"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
||||||
isDisabled={page === 0}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
label="Next"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((p) => Math.min(Math.ceil(displayBookings.length / rpp) - 1, p + 1))}
|
|
||||||
isDisabled={page >= Math.ceil(displayBookings.length / rpp) - 1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -657,31 +541,6 @@ export default function Bookings() {
|
|||||||
clients={clients}
|
clients={clients}
|
||||||
surveys={surveys}
|
surveys={surveys}
|
||||||
/>
|
/>
|
||||||
<style>{`
|
</Box>
|
||||||
.action-btn:hover {
|
|
||||||
background-color: #f1f5f9;
|
|
||||||
}
|
|
||||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputStyle = {
|
|
||||||
width: '100%',
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
border: '1px solid #cbd5e1',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
color: '#1e293b',
|
|
||||||
outline: 'none',
|
|
||||||
boxSizing: 'border-box',
|
|
||||||
transition: 'border-color 0.2s',
|
|
||||||
backgroundColor: '#ffffff'
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectStyle = {
|
|
||||||
...inputStyle,
|
|
||||||
height: '38px',
|
|
||||||
cursor: 'pointer'
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Box, Stack, Typography, TextField, Button } from '@mui/material';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
||||||
import { Text, Heading } from '@astryxdesign/core/Text';
|
|
||||||
import { Mail } from 'lucide-react';
|
|
||||||
|
|
||||||
import Logo from '@/components/Logo';
|
import Logo from '@/components/Logo';
|
||||||
|
|
||||||
@@ -17,73 +14,67 @@ export default function ComingSoon() {
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: '24px',
|
p: 3,
|
||||||
textAlign: 'center',
|
textAlign: 'center'
|
||||||
backgroundColor: '#f8fafc',
|
|
||||||
fontFamily: 'system-ui, sans-serif'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px', alignItems: 'center', maxWidth: '620px', width: '100%' }}>
|
<Stack spacing={3} alignItems="center" sx={{ maxWidth: 620, width: '100%' }}>
|
||||||
<Logo />
|
<Logo />
|
||||||
<Heading level={1} type="display-2">Coming Soon</Heading>
|
<Typography variant="h2" sx={{ fontWeight: 800, color: 'grey.800' }}>Coming Soon</Typography>
|
||||||
<Text type="large" color="secondary">Something new is on its way</Text>
|
<Typography variant="body1" color="text.secondary">Something new is on its way</Typography>
|
||||||
|
|
||||||
{/* Countdown */}
|
{/* Countdown */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'row', gap: '16px', alignItems: 'center', justifyContent: 'center', flexWrap: 'wrap' }}>
|
<Stack direction="row" spacing={{ xs: 1, sm: 2 }} alignItems="center" justifyContent="center">
|
||||||
{COUNTDOWN.map((c, i) => (
|
{COUNTDOWN.map((c, i) => (
|
||||||
<div key={c.label} style={{ display: 'flex', flexDirection: 'row', gap: '16px', alignItems: 'center' }}>
|
<Stack key={c.label} direction="row" spacing={{ xs: 1, sm: 2 }} alignItems="center">
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
width: '84px',
|
width: { xs: 64, sm: 84 },
|
||||||
padding: '16px 0',
|
py: 2,
|
||||||
borderRadius: '12px',
|
borderRadius: 2,
|
||||||
backgroundColor: 'rgba(192, 18, 39, 0.08)',
|
bgcolor: 'primary.lighter',
|
||||||
border: '1px solid rgba(192, 18, 39, 0.2)'
|
border: 1,
|
||||||
|
borderColor: 'primary.light'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: '2rem', fontWeight: 800, color: '#C01227', lineHeight: 1 }}>
|
<Typography variant="h2" sx={{ fontWeight: 800, color: 'primary.main', lineHeight: 1 }}>
|
||||||
{String(c.value).padStart(2, '0')}
|
{String(c.value).padStart(2, '0')}
|
||||||
</div>
|
</Typography>
|
||||||
<div style={{ fontSize: '0.65rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '1px', marginTop: '4px' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||||
{c.label}
|
{c.label}
|
||||||
</div>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
{i < COUNTDOWN.length - 1 && (
|
{i < COUNTDOWN.length - 1 && (
|
||||||
<div style={{ fontSize: '2rem', fontWeight: 800, color: '#C01227' }}>:</div>
|
<Typography variant="h2" sx={{ fontWeight: 800, color: 'primary.main' }}>:</Typography>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Stack>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Stack>
|
||||||
|
|
||||||
{/* Subscribe */}
|
{/* Subscribe */}
|
||||||
<div style={{ width: '100%', maxWidth: '480px', marginTop: '16px' }}>
|
<Box sx={{ width: '100%', maxWidth: 480 }}>
|
||||||
<div style={{ marginBottom: '12px' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||||
<Text type="body" color="secondary">
|
|
||||||
Be the first to be notified when Doormile launches.
|
Be the first to be notified when Doormile launches.
|
||||||
</Text>
|
</Typography>
|
||||||
</div>
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||||||
<div style={{ display: 'flex', gap: '12px', flexDirection: 'row', alignItems: 'flex-start', flexWrap: 'wrap' }} className="subscribe-row">
|
<TextField
|
||||||
<div style={{ flexGrow: 1, minWidth: '200px' }}>
|
fullWidth
|
||||||
<TextInput
|
size="small"
|
||||||
label="Email address"
|
|
||||||
isLabelHidden
|
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Enter your email"
|
placeholder="Enter your email"
|
||||||
startIcon={<Mail size={16} />}
|
|
||||||
value={email}
|
value={email}
|
||||||
onChange={setEmail}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
<Button variant="contained" sx={{ whiteSpace: 'nowrap', px: 3 }}>Notify Me</Button>
|
||||||
<Button variant="primary" label="Notify Me" onClick={() => {}} />
|
</Stack>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Stack>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,34 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Box, Stack, Typography, Button } from '@mui/material';
|
||||||
import { Text, Heading } from '@astryxdesign/core/Text';
|
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined';
|
||||||
import { Home } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function Error404() {
|
export default function Error404() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: '24px',
|
p: 3,
|
||||||
textAlign: 'center',
|
textAlign: 'center'
|
||||||
backgroundColor: '#f8fafc',
|
|
||||||
fontFamily: 'system-ui, sans-serif'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', alignItems: 'center' }}>
|
<Stack spacing={2} alignItems="center">
|
||||||
<span style={{ fontWeight: 900, fontSize: 'clamp(3.5rem, 18vw, 8rem)', lineHeight: 1, color: '#C01227' }}>
|
<Typography sx={{ fontWeight: 900, fontSize: { xs: '6rem', md: '9rem' }, lineHeight: 1, color: 'primary.main' }}>
|
||||||
404
|
404
|
||||||
</span>
|
</Typography>
|
||||||
<div style={{ width: '64px', height: '4px', borderRadius: '2px', backgroundColor: '#C01227' }} />
|
<Box sx={{ width: 64, height: 4, borderRadius: 2, bgcolor: 'primary.main' }} />
|
||||||
<Heading level={2}>Page Not Found</Heading>
|
<Typography variant="h3" sx={{ fontWeight: 700, color: 'grey.800' }}>Page Not Found</Typography>
|
||||||
<div style={{ maxWidth: '460px' }}>
|
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 460 }}>
|
||||||
<Text type="body" color="secondary">
|
|
||||||
The page you are looking for was moved, removed, renamed, or might never exist!
|
The page you are looking for was moved, removed, renamed, or might never exist!
|
||||||
</Text>
|
</Typography>
|
||||||
</div>
|
<Button variant="contained" size="large" startIcon={<HomeOutlinedIcon />} onClick={() => navigate('/dashboard')} sx={{ mt: 1 }}>
|
||||||
<Button variant="primary" icon={<Home size={16} />} onClick={() => navigate('/dashboard')} label="Back To Home" />
|
Back To Home
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</Stack>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,34 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Box, Stack, Typography, Button } from '@mui/material';
|
||||||
import { Text, Heading } from '@astryxdesign/core/Text';
|
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined';
|
||||||
import { Home } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function Error500() {
|
export default function Error500() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: '24px',
|
p: 3,
|
||||||
textAlign: 'center',
|
textAlign: 'center'
|
||||||
backgroundColor: '#f8fafc',
|
|
||||||
fontFamily: 'system-ui, sans-serif'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', alignItems: 'center' }}>
|
<Stack spacing={2} alignItems="center">
|
||||||
<span style={{ fontWeight: 900, fontSize: 'clamp(3.5rem, 18vw, 8rem)', lineHeight: 1, color: '#C01227' }}>
|
<Typography sx={{ fontWeight: 900, fontSize: { xs: '6rem', md: '9rem' }, lineHeight: 1, color: 'primary.main' }}>
|
||||||
500
|
500
|
||||||
</span>
|
</Typography>
|
||||||
<div style={{ width: '64px', height: '4px', borderRadius: '2px', backgroundColor: '#C01227' }} />
|
<Box sx={{ width: 64, height: 4, borderRadius: 2, bgcolor: 'primary.main' }} />
|
||||||
<Heading level={2}>Internal Server Error</Heading>
|
<Typography variant="h3" sx={{ fontWeight: 700, color: 'grey.800' }}>Internal Server Error</Typography>
|
||||||
<div style={{ maxWidth: '460px' }}>
|
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 460 }}>
|
||||||
<Text type="body" color="secondary">
|
|
||||||
Server error 500. We are fixing the problem. Please try again at a later stage.
|
Server error 500. We are fixing the problem. Please try again at a later stage.
|
||||||
</Text>
|
</Typography>
|
||||||
</div>
|
<Button variant="contained" size="large" startIcon={<HomeOutlinedIcon />} onClick={() => navigate('/dashboard')} sx={{ mt: 1 }}>
|
||||||
<Button variant="primary" icon={<Home size={16} />} onClick={() => navigate('/dashboard')} label="Back To Home" />
|
Back To Home
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</Stack>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,45 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Box, Stack, Typography, Button } from '@mui/material';
|
||||||
import { Text, Heading } from '@astryxdesign/core/Text';
|
import ConstructionOutlinedIcon from '@mui/icons-material/ConstructionOutlined';
|
||||||
import { Construction, Home } from 'lucide-react';
|
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined';
|
||||||
|
|
||||||
export default function UnderConstruction() {
|
export default function UnderConstruction() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: '24px',
|
p: 3,
|
||||||
textAlign: 'center',
|
textAlign: 'center'
|
||||||
backgroundColor: '#f8fafc',
|
|
||||||
fontFamily: 'system-ui, sans-serif'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', alignItems: 'center' }}>
|
<Stack spacing={2.5} alignItems="center">
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
width: '110px',
|
width: 110,
|
||||||
height: '110px',
|
height: 110,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
backgroundColor: 'rgba(192, 18, 39, 0.08)',
|
bgcolor: 'primary.lighter',
|
||||||
color: '#C01227',
|
color: 'primary.main',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center'
|
justifyContent: 'center'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Construction size={56} />
|
<ConstructionOutlinedIcon sx={{ fontSize: 56 }} />
|
||||||
</div>
|
</Box>
|
||||||
<Heading level={2}>Under Construction</Heading>
|
<Typography variant="h3" sx={{ fontWeight: 700, color: 'grey.800' }}>Under Construction</Typography>
|
||||||
<div style={{ maxWidth: '460px' }}>
|
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 460 }}>
|
||||||
<Text type="body" color="secondary">
|
|
||||||
Hey! Please check out this site later. We are doing some maintenance on it right now.
|
Hey! Please check out this site later. We are doing some maintenance on it right now.
|
||||||
</Text>
|
</Typography>
|
||||||
</div>
|
<Button variant="contained" size="large" startIcon={<HomeOutlinedIcon />} onClick={() => navigate('/dashboard')} sx={{ mt: 1 }}>
|
||||||
<Button variant="primary" icon={<Home size={16} />} onClick={() => navigate('/dashboard')} label="Back To Home" />
|
Back To Home
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</Stack>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Plus, Search, Edit, ChevronDown, ChevronUp, MapPin, LayoutGrid } from 'lucide-react';
|
import {
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
Card, Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
Typography, TextField, InputAdornment, CircularProgress, MenuItem,
|
||||||
import { Table, proportional, pixel } from '@astryxdesign/core/Table';
|
Select, FormControl, OutlinedInput, alpha, Avatar, Chip, Stack, Collapse, Button,
|
||||||
import { Badge } from '@astryxdesign/core/Badge';
|
Dialog, DialogTitle, DialogContent, DialogActions, IconButton, Grid, Tooltip, Alert, Snackbar
|
||||||
import { Text, Heading } from '@astryxdesign/core/Text';
|
} from '@mui/material';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import { FormLayout } from '@astryxdesign/core/FormLayout';
|
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
|
||||||
import { Banner } from '@astryxdesign/core/Banner';
|
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||||
import { Item } from '@astryxdesign/core/Item';
|
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
|
||||||
import { Icon } from '@astryxdesign/core/Icon';
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
|
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
import EmptyState from '@/components/EmptyState';
|
|
||||||
import { fetchUsers } from '@/utils/apiClient';
|
import { fetchUsers } from '@/utils/apiClient';
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || 'https://api.doormile.com/api/v1';
|
const API_BASE = import.meta.env.VITE_API_BASE || 'https://api.doormile.com/api/v1';
|
||||||
@@ -61,6 +60,8 @@ async function apiSavePricing(data) {
|
|||||||
const ZONES = ['Local / Same', 'Within Tamilnadu', 'Interstate India'];
|
const ZONES = ['Local / Same', 'Within Tamilnadu', 'Interstate India'];
|
||||||
const WEIGHT_SLABS = ['< 500g', '500g - 1kg', '1kg - 2kg', '2kg - 5kg', '> 5kg'];
|
const WEIGHT_SLABS = ['< 500g', '500g - 1kg', '1kg - 2kg', '2kg - 5kg', '> 5kg'];
|
||||||
|
|
||||||
|
// isCell=true → editing a specific cell (company/slab/zone locked, only rate editable)
|
||||||
|
// isCell=false → "Add Rate" button (all fields editable)
|
||||||
function PricingFormDialog({ open, onClose, onSave, initialData, isCell = false, pricingList = [], providerColumns = [], providerUsesServiceType = false }) {
|
function PricingFormDialog({ open, onClose, onSave, initialData, isCell = false, pricingList = [], providerColumns = [], providerUsesServiceType = false }) {
|
||||||
const [formData, setFormData] = useState({});
|
const [formData, setFormData] = useState({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -73,6 +74,9 @@ function PricingFormDialog({ open, onClose, onSave, initialData, isCell = false,
|
|||||||
}
|
}
|
||||||
}, [open, initialData]);
|
}, [open, initialData]);
|
||||||
|
|
||||||
|
const set = (field) => (e) => setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
||||||
|
|
||||||
|
// Column key: zone if available, otherwise service_type (e.g. DTDC)
|
||||||
const colKey = (p) => ((p.zone || '').trim() || (p.service_type || '').trim());
|
const colKey = (p) => ((p.zone || '').trim() || (p.service_type || '').trim());
|
||||||
|
|
||||||
const findExistingRecord = (data) => {
|
const findExistingRecord = (data) => {
|
||||||
@@ -99,6 +103,7 @@ function PricingFormDialog({ open, onClose, onSave, initialData, isCell = false,
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
let dataToSave = { ...formData };
|
let dataToSave = { ...formData };
|
||||||
|
// Route the column value to the right field based on provider type
|
||||||
if (!isCell) {
|
if (!isCell) {
|
||||||
dataToSave = {
|
dataToSave = {
|
||||||
...dataToSave,
|
...dataToSave,
|
||||||
@@ -121,93 +126,85 @@ function PricingFormDialog({ open, onClose, onSave, initialData, isCell = false,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
const zoneOptions = providerColumns.length > 0 ? providerColumns : ZONES;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog isOpen={open} onOpenChange={(o) => { if (!o) onClose(); }} width={440} purpose="form">
|
<>
|
||||||
<Layout
|
<Snackbar
|
||||||
header={
|
open={!!error}
|
||||||
<DialogHeader
|
autoHideDuration={6000}
|
||||||
title={initialData?.id ? 'Edit Rate' : (isCell ? 'Add Rate' : 'Add New Rate')}
|
onClose={() => setError(null)}
|
||||||
onOpenChange={(o) => { if (!o) onClose(); }}
|
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
/>
|
sx={{ zIndex: 9999 }}
|
||||||
}
|
>
|
||||||
content={
|
<Alert onClose={() => setError(null)} severity="error" variant="filled" sx={{ width: '100%', boxShadow: 3 }}>
|
||||||
<LayoutContent>
|
{error}
|
||||||
<FormLayout>
|
</Alert>
|
||||||
{error && <Banner status="error" title={error} />}
|
</Snackbar>
|
||||||
|
|
||||||
|
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle sx={{ fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
{isCell ? (initialData?.id ? 'Edit Rate' : 'Add Rate') : (initialData?.id ? 'Edit Rate' : 'Add New Rate')}
|
||||||
|
<IconButton size="small" onClick={onClose} disabled={saving}><CloseIcon fontSize="small" /></IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Grid container spacing={2}>
|
||||||
{isCell ? (
|
{isCell ? (
|
||||||
<Card style={{ padding: '12px 16px', backgroundColor: 'rgba(192, 18, 39, 0.04)', border: '1px solid rgba(192, 18, 39, 0.15)' }}>
|
<Grid item xs={12}>
|
||||||
<Text type="label" color="secondary">Updating rate for</Text>
|
<Box sx={{ p: 1.5, borderRadius: 2, bgcolor: alpha('#c01227', 0.04), border: '1px solid', borderColor: alpha('#c01227', 0.15), mb: 0.5 }}>
|
||||||
<div style={{ marginTop: '4px' }}>
|
<Typography variant="caption" sx={{ fontWeight: 700, color: 'grey.500', letterSpacing: 0.5, display: 'block', mb: 0.5 }}>UPDATING RATE FOR</Typography>
|
||||||
<Heading level={5}>{formData.company}</Heading>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'grey.900' }}>{formData.company}</Typography>
|
||||||
</div>
|
<Typography variant="body2" sx={{ color: 'grey.600', mt: 0.25 }}>{formData.weight_slab} · {formData.zone || formData.service_type}</Typography>
|
||||||
<div style={{ marginTop: '2px' }}>
|
</Box>
|
||||||
<Text type="supporting" color="secondary">{formData.weight_slab} · {formData.zone || formData.service_type}</Text>
|
</Grid>
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<TextInput
|
<Grid item xs={12}>
|
||||||
label="Company"
|
<TextField fullWidth label="Company *" value={formData.company || ''} onChange={set('company')} size="small" />
|
||||||
isRequired
|
</Grid>
|
||||||
value={formData.company || ''}
|
<Grid item xs={12}>
|
||||||
onChange={(v) => setFormData((prev) => ({ ...prev, company: v }))}
|
<TextField select fullWidth label="Weight Slab *" value={formData.weight_slab || ''} onChange={set('weight_slab')} size="small">
|
||||||
/>
|
{WEIGHT_SLABS.map(s => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||||
<Selector
|
</TextField>
|
||||||
label="Weight Slab"
|
</Grid>
|
||||||
isRequired
|
<Grid item xs={12}>
|
||||||
placeholder="Select Weight Slab"
|
<TextField
|
||||||
options={WEIGHT_SLABS}
|
select fullWidth
|
||||||
value={formData.weight_slab || ''}
|
label={providerUsesServiceType ? 'Service Type *' : 'Zone / Service Type *'}
|
||||||
onChange={(v) => setFormData((prev) => ({ ...prev, weight_slab: v || '' }))}
|
|
||||||
/>
|
|
||||||
<Selector
|
|
||||||
label={providerUsesServiceType ? 'Service Type' : 'Zone / Service Type'}
|
|
||||||
isRequired
|
|
||||||
placeholder="Select Zone / Service"
|
|
||||||
options={zoneOptions}
|
|
||||||
value={formData.zone || formData.service_type || ''}
|
value={formData.zone || formData.service_type || ''}
|
||||||
onChange={(v) => setFormData((prev) => ({
|
onChange={(e) => setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
zone: providerUsesServiceType ? '' : (v || ''),
|
zone: providerUsesServiceType ? '' : e.target.value,
|
||||||
service_type: providerUsesServiceType ? (v || '') : ''
|
service_type: providerUsesServiceType ? e.target.value : ''
|
||||||
}))}
|
}))}
|
||||||
/>
|
size="small"
|
||||||
|
>
|
||||||
|
{(providerColumns.length > 0 ? providerColumns : ZONES).map(z => (
|
||||||
|
<MenuItem key={z} value={z}>{z}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<Grid item xs={12}>
|
||||||
<TextInput
|
<TextField
|
||||||
label="Rate (₹)"
|
fullWidth
|
||||||
isRequired
|
label="Rate (₹) *"
|
||||||
hasClear
|
|
||||||
hasAutoFocus
|
|
||||||
placeholder="e.g. 25 or 25-35"
|
|
||||||
value={formData.rate || ''}
|
value={formData.rate || ''}
|
||||||
onChange={(v) => setFormData((prev) => ({ ...prev, rate: v }))}
|
onChange={set('rate')}
|
||||||
/>
|
size="small"
|
||||||
</FormLayout>
|
placeholder="e.g. 25 or 25-35"
|
||||||
</LayoutContent>
|
autoFocus
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<LayoutFooter hasDivider>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
|
||||||
<Button label="Cancel" variant="ghost" onClick={onClose} isDisabled={saving} />
|
|
||||||
<Button
|
|
||||||
label="Save Rate"
|
|
||||||
variant="primary"
|
|
||||||
onClick={handleSave}
|
|
||||||
isDisabled={saving}
|
|
||||||
isLoading={saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</LayoutFooter>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ p: 2, bgcolor: 'grey.50' }}>
|
||||||
|
<Button onClick={onClose} disabled={saving} color="inherit">Cancel</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving} variant="contained" sx={{ bgcolor: '#c01227', '&:hover': { bgcolor: '#a00f20' }, borderRadius: 2 }}>
|
||||||
|
{saving ? 'Saving...' : 'Save Rate'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,9 +223,9 @@ export default function Pricing() {
|
|||||||
const [selectedProvider, setSelectedProvider] = useState('');
|
const [selectedProvider, setSelectedProvider] = useState('');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [locationsOpen, setLocationsOpen] = useState(false);
|
const [locationsOpen, setLocationsOpen] = useState(false);
|
||||||
const [locationSearch, setLocationSearch] = useState('');
|
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [editingRecord, setEditingRecord] = useState(null);
|
const [editingRecord, setEditingRecord] = useState(null);
|
||||||
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const [isCellEdit, setIsCellEdit] = useState(false);
|
const [isCellEdit, setIsCellEdit] = useState(false);
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
|
|
||||||
@@ -256,6 +253,7 @@ export default function Pricing() {
|
|||||||
return loadData();
|
return loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Ensure absolutely unique companies and default selection
|
||||||
const companies = [...new Set(pricingList.map(p => p.company).filter(Boolean))];
|
const companies = [...new Set(pricingList.map(p => p.company).filter(Boolean))];
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProvider && companies.length > 0) {
|
if (!selectedProvider && companies.length > 0) {
|
||||||
@@ -264,23 +262,19 @@ export default function Pricing() {
|
|||||||
}, [companies, selectedProvider]);
|
}, [companies, selectedProvider]);
|
||||||
|
|
||||||
const providerRates = pricingList.filter(p => p.company === selectedProvider);
|
const providerRates = pricingList.filter(p => p.company === selectedProvider);
|
||||||
|
// Normalize: lowercase, trim edges, collapse internal spaces
|
||||||
const normCompany = (s) => (s || '').toLowerCase().trim().replace(/\s+/g, ' ');
|
const normCompany = (s) => (s || '').toLowerCase().trim().replace(/\s+/g, ' ');
|
||||||
const normProvider = normCompany(selectedProvider);
|
const normProvider = normCompany(selectedProvider);
|
||||||
const providerSurveys = surveysList.filter(p => {
|
const providerSurveys = surveysList.filter(p => {
|
||||||
const pn = normCompany(p.company);
|
const pn = normCompany(p.company);
|
||||||
|
// Exact after normalising, or one name starts with the other (handles "Pvt Ltd" suffixes)
|
||||||
return pn === normProvider || pn.startsWith(normProvider) || normProvider.startsWith(pn);
|
return pn === normProvider || pn.startsWith(normProvider) || normProvider.startsWith(pn);
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredSurveys = useMemo(() => {
|
// Sort by ID desc so most recent record wins when duplicates exist
|
||||||
if (!locationSearch.trim()) return providerSurveys;
|
|
||||||
const q = locationSearch.toLowerCase();
|
|
||||||
return providerSurveys.filter((s) =>
|
|
||||||
(s.area || '').toLowerCase().includes(q) || (s.address || '').toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
}, [providerSurveys, locationSearch]);
|
|
||||||
|
|
||||||
const sortedProviderRates = [...providerRates].sort((a, b) => (b.id || 0) - (a.id || 0));
|
const sortedProviderRates = [...providerRates].sort((a, b) => (b.id || 0) - (a.id || 0));
|
||||||
|
|
||||||
|
// Extract numeric weight from a slab string for sorting rows logically
|
||||||
const slabSortWeight = (slab) => {
|
const slabSortWeight = (slab) => {
|
||||||
const s = (slab || '').toLowerCase().replace(/\s+/g, '');
|
const s = (slab || '').toLowerCase().replace(/\s+/g, '');
|
||||||
const nums = s.match(/[\d.]+/g);
|
const nums = s.match(/[\d.]+/g);
|
||||||
@@ -292,23 +286,15 @@ export default function Pricing() {
|
|||||||
return grams;
|
return grams;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeZone = (z) => {
|
// Use zone if available, fall back to service_type (e.g. DTDC has service_type instead of zone)
|
||||||
if (!z) return '';
|
const getColKey = (r) => ((r.zone || '').trim() || (r.service_type || '').trim());
|
||||||
let val = z.trim();
|
|
||||||
const lower = val.toLowerCase().replace(/\s+/g, '');
|
|
||||||
if (lower === 'tamilnadu') return 'Tamil Nadu';
|
|
||||||
if (lower === 'karanataka' || lower === 'karnataka') return 'Karnataka';
|
|
||||||
if (lower === 'local' || lower === 'localsame' || lower === 'local/same') return 'Local';
|
|
||||||
if (lower === 'central/northindia' || lower === 'central/north') return 'Central/North India';
|
|
||||||
return val;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getColKey = (r) => normalizeZone((r.zone || '').trim() || (r.service_type || '').trim());
|
|
||||||
const providerUsesServiceType = sortedProviderRates.length > 0 &&
|
const providerUsesServiceType = sortedProviderRates.length > 0 &&
|
||||||
sortedProviderRates.every(r => !(r.zone || '').trim());
|
sortedProviderRates.every(r => !(r.zone || '').trim());
|
||||||
|
|
||||||
|
// Dynamic columns — whichever field the provider uses for differentiation
|
||||||
const providerZones = [...new Set(sortedProviderRates.map(getColKey).filter(Boolean))].sort();
|
const providerZones = [...new Set(sortedProviderRates.map(getColKey).filter(Boolean))].sort();
|
||||||
|
|
||||||
|
// Dynamic rows: every unique weight slab, sorted by actual weight
|
||||||
const allProviderSlabs = [...new Set(sortedProviderRates.map(r => (r.weight_slab || '').trim()).filter(Boolean))]
|
const allProviderSlabs = [...new Set(sortedProviderRates.map(r => (r.weight_slab || '').trim()).filter(Boolean))]
|
||||||
.sort((a, b) => slabSortWeight(a) - slabSortWeight(b));
|
.sort((a, b) => slabSortWeight(a) - slabSortWeight(b));
|
||||||
|
|
||||||
@@ -316,21 +302,260 @@ export default function Pricing() {
|
|||||||
? allProviderSlabs.filter(s => s.toLowerCase().includes(searchQuery.toLowerCase()))
|
? allProviderSlabs.filter(s => s.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
: allProviderSlabs;
|
: allProviderSlabs;
|
||||||
|
|
||||||
const matrixData = useMemo(
|
return (
|
||||||
() => displaySlabs.map((slab) => ({
|
<Box sx={{ pb: 6, minHeight: '80vh', bgcolor: '#f8fafc', px: { xs: 2, md: 4 }, pt: 3 }}>
|
||||||
slab,
|
<PageHeader
|
||||||
rates: Object.fromEntries(
|
title="Logistics Pricing Board"
|
||||||
providerZones.map((zone) => [
|
breadcrumbs={[{ label: 'Pricing Overview' }]}
|
||||||
zone,
|
/>
|
||||||
sortedProviderRates.find(r => (r.weight_slab || '').trim() === slab && getColKey(r) === zone) || null
|
|
||||||
])
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
[displaySlabs, providerZones, sortedProviderRates]
|
|
||||||
);
|
|
||||||
|
|
||||||
const matrixColumns = useMemo(() => {
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4, mt: 3, maxWidth: 1200, mx: 'auto' }}>
|
||||||
const openCellEdit = (slab, zone, match) => {
|
|
||||||
|
{/* Modern Premium Control Panel */}
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
borderRadius: 4,
|
||||||
|
bgcolor: '#ffffff',
|
||||||
|
boxShadow: '0 20px 40px -10px rgba(0,0,0,0.03)',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.100',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'column', md: 'row' },
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 3
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: { xs: '100%', md: 'auto' } }}>
|
||||||
|
<Box sx={{ width: 48, height: 48, borderRadius: 3, bgcolor: 'primary.lighter', color: 'primary.main', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<LocalShippingOutlinedIcon />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary', letterSpacing: 0.8, textTransform: 'uppercase' }}>
|
||||||
|
Active Logistics Provider
|
||||||
|
</Typography>
|
||||||
|
<FormControl sx={{ mt: 0.5, minWidth: 260, display: 'block' }}>
|
||||||
|
<Select
|
||||||
|
value={selectedProvider || ''}
|
||||||
|
onChange={(e) => setSelectedProvider(e.target.value)}
|
||||||
|
displayEmpty
|
||||||
|
IconComponent={StorefrontOutlinedIcon}
|
||||||
|
input={<OutlinedInput sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
bgcolor: alpha('#e2e8f0', 0.4),
|
||||||
|
'& fieldset': { border: 'none' },
|
||||||
|
'&:hover': { bgcolor: alpha('#e2e8f0', 0.6) },
|
||||||
|
'&.Mui-focused': { bgcolor: '#fff', '& fieldset': { border: '2px solid', borderColor: 'primary.main' } }
|
||||||
|
}} />}
|
||||||
|
MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 24px rgba(0,0,0,0.1)' } } }}
|
||||||
|
>
|
||||||
|
{companies.length === 0 ? (
|
||||||
|
<MenuItem value="" disabled>Loading Providers...</MenuItem>
|
||||||
|
) : (
|
||||||
|
companies.map(c => (
|
||||||
|
<MenuItem key={c} value={c} sx={{ fontWeight: 600, py: 1.5, borderRadius: 1, mx: 1 }}>{c}</MenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
placeholder="Search weight slabs..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <InputAdornment position="start"><SearchIcon color="primary" /></InputAdornment>,
|
||||||
|
sx: {
|
||||||
|
borderRadius: 50,
|
||||||
|
bgcolor: alpha('#e2e8f0', 0.3),
|
||||||
|
'& fieldset': { border: 'none' },
|
||||||
|
'&:hover': { bgcolor: alpha('#e2e8f0', 0.5) },
|
||||||
|
'&.Mui-focused': { bgcolor: '#fff', '& fieldset': { border: '2px solid', borderColor: 'primary.main' } }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setEditingRecord({ company: selectedProvider, weight_slab: '', zone: '', rate: '' }); setIsCellEdit(false); setFormOpen(true); }} sx={{ bgcolor: '#c01227', '&:hover': { bgcolor: '#a00f20' }, borderRadius: 2, py: 1.2, fontWeight: 700, whiteSpace: 'nowrap' }}>
|
||||||
|
Add Rate
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={isEditMode ? "contained" : "outlined"}
|
||||||
|
startIcon={<EditOutlinedIcon />}
|
||||||
|
onClick={() => setIsEditMode(!isEditMode)}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2, py: 1.2, fontWeight: 700, whiteSpace: 'nowrap',
|
||||||
|
color: isEditMode ? 'white' : 'grey.700',
|
||||||
|
borderColor: isEditMode ? 'transparent' : 'grey.300',
|
||||||
|
bgcolor: isEditMode ? 'grey.800' : 'transparent',
|
||||||
|
'&:hover': { bgcolor: isEditMode ? 'grey.900' : alpha('#000', 0.05), borderColor: isEditMode ? 'transparent' : 'grey.400' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEditMode ? 'Done' : 'Edit'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Minimalist Collapsible Provider Profile Widget */}
|
||||||
|
{selectedProvider && providerSurveys.length > 0 && (
|
||||||
|
<Card
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: locationsOpen ? '#c01227' : 'grey.200',
|
||||||
|
bgcolor: 'white',
|
||||||
|
transition: 'border-color 0.3s ease, box-shadow 0.3s ease',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: '#c01227',
|
||||||
|
boxShadow: '0 8px 30px rgba(192, 18, 39, 0.08)',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: 'column', md: 'row' }} spacing={4} sx={{ p: { xs: 3, md: 3.5 }, cursor: 'pointer', userSelect: 'none' }}
|
||||||
|
alignItems={{ xs: 'flex-start', md: 'center' }} justifyContent="space-between"
|
||||||
|
onClick={() => setLocationsOpen(!locationsOpen)}
|
||||||
|
>
|
||||||
|
|
||||||
|
{/* Avatar & Title */}
|
||||||
|
<Stack direction="row" spacing={3} alignItems="center" sx={{ minWidth: 260 }}>
|
||||||
|
<Avatar sx={{ width: 68, height: 68, bgcolor: alpha('#c01227', 0.08), color: '#c01227', fontWeight: 800, fontSize: '1.8rem', borderRadius: 3 }}>
|
||||||
|
{(selectedProvider || 'A')[0].toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="overline" sx={{ color: 'grey.500', fontWeight: 700, letterSpacing: 1.2 }}>LOGISTICS PARTNER</Typography>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 800, color: 'grey.900', lineHeight: 1.1, mt: 0.25 }}>{selectedProvider}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'grey.600', mt: 1, fontWeight: 500, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<PlaceOutlinedIcon sx={{ fontSize: 16, mr: 0.5, color: '#c01227' }} />
|
||||||
|
{providerSurveys.length} Registered Location{providerSurveys.length !== 1 && 's'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Badges & Action */}
|
||||||
|
<Stack direction="row" spacing={3} alignItems="center">
|
||||||
|
<Stack direction="row" spacing={1} sx={{ display: { xs: 'none', md: 'flex' } }}>
|
||||||
|
<Chip size="small" label="First Mile" sx={{ bgcolor: alpha('#c01227', 0.08), color: '#c01227', fontWeight: 700, borderRadius: 1.5 }} />
|
||||||
|
<Chip size="small" label="Mid Mile" sx={{ bgcolor: alpha('#c01227', 0.08), color: '#c01227', fontWeight: 700, borderRadius: 1.5 }} />
|
||||||
|
<Chip size="small" label="Last Mile" sx={{ bgcolor: alpha('#c01227', 0.08), color: '#c01227', fontWeight: 700, borderRadius: 1.5 }} />
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setLocationsOpen(!locationsOpen); }}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2, py: 1, px: 3, fontWeight: 700,
|
||||||
|
color: locationsOpen ? '#c01227' : 'grey.700',
|
||||||
|
bgcolor: locationsOpen ? alpha('#c01227', 0.1) : 'grey.100',
|
||||||
|
'&:hover': { bgcolor: alpha('#c01227', 0.15), color: '#c01227' }
|
||||||
|
}}
|
||||||
|
endIcon={locationsOpen ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
|
||||||
|
>
|
||||||
|
{locationsOpen ? 'Hide Coverage' : 'View Coverage Map'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Collapse in={locationsOpen} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ p: 3, pt: 0, borderTop: '1px dashed', borderColor: 'grey.200', bgcolor: 'grey.50', borderBottomLeftRadius: 16, borderBottomRightRadius: 16 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2, mt: 3 }}>
|
||||||
|
<PlaceOutlinedIcon sx={{ color: '#c01227', fontSize: 20, mr: 1 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: 'grey.600', fontWeight: 700, letterSpacing: 1 }}>ALL SERVICED AREAS:</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
|
{providerSurveys.map((s, idx) => (
|
||||||
|
<Chip
|
||||||
|
key={s.id ?? idx}
|
||||||
|
label={s.area || s.address}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2, fontWeight: 600, px: 1, py: 2.5,
|
||||||
|
bgcolor: 'white', color: 'grey.800', border: '1px solid', borderColor: 'grey.300', boxShadow: '0 2px 8px rgba(0,0,0,0.02)',
|
||||||
|
'&:hover': { borderColor: '#c01227', color: '#c01227', bgcolor: alpha('#c01227', 0.05), boxShadow: '0 4px 12px rgba(192,18,39,0.08)' },
|
||||||
|
transition: 'border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pricing Data Grid */}
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
borderRadius: 4,
|
||||||
|
boxShadow: '0 24px 48px -12px rgba(0,0,0,0.05)',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'grey.200',
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: '#fff'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 10 }}>
|
||||||
|
<CircularProgress size={48} thickness={4} sx={{ color: 'primary.light' }} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<TableContainer>
|
||||||
|
<Table sx={{ minWidth: 700 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ bgcolor: alpha('#f8fafc', 0.8) }}>
|
||||||
|
<TableCell sx={{ py: 2, px: 4, fontWeight: 700, color: 'grey.500', fontSize: '0.75rem', letterSpacing: 1.2, textTransform: 'uppercase', borderBottom: '2px solid', borderColor: 'grey.100' }}>Weight Slab</TableCell>
|
||||||
|
{providerZones.map(zone => (
|
||||||
|
<TableCell key={zone} align="center" sx={{ py: 2, fontWeight: 700, color: 'grey.500', fontSize: '0.75rem', letterSpacing: 1.2, textTransform: 'uppercase', borderBottom: '2px solid', borderColor: 'grey.100' }}>
|
||||||
|
{zone}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{providerZones.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={2} align="center" sx={{ py: 8 }}>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ fontWeight: 500 }}>
|
||||||
|
No pricing data for this provider yet.
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : displaySlabs.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={providerZones.length + 1} align="center" sx={{ py: 8 }}>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ fontWeight: 500 }}>
|
||||||
|
No weight slabs match your search.
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
displaySlabs.map((slab) => (
|
||||||
|
<TableRow
|
||||||
|
key={slab}
|
||||||
|
sx={{
|
||||||
|
transition: 'background-color 0.15s',
|
||||||
|
'&:hover': { bgcolor: alpha('#f1f5f9', 0.5) },
|
||||||
|
'&:last-child td': { borderBottom: 0 }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell sx={{ px: 4, py: 2, fontWeight: 600, color: 'grey.800', fontSize: '0.9rem', borderBottom: '1px solid', borderColor: 'grey.100' }}>
|
||||||
|
{slab}
|
||||||
|
</TableCell>
|
||||||
|
{providerZones.map(zone => {
|
||||||
|
// Exact match — use getColKey so DTDC service_type columns work too
|
||||||
|
const match = sortedProviderRates.find(r => (r.weight_slab || '').trim() === slab && getColKey(r) === zone);
|
||||||
|
const hasRate = !!match;
|
||||||
|
const rateStr = match ? `₹${match.rate}` : '—';
|
||||||
|
|
||||||
|
let tooltipText = '';
|
||||||
|
if (match && match.updated_by) {
|
||||||
|
const updater = users.find(u => u.id === match.updated_by);
|
||||||
|
tooltipText = `Last updated by: ${updater ? updater.first_name : 'ID ' + match.updated_by}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCellEdit = () => {
|
||||||
setEditingRecord({
|
setEditingRecord({
|
||||||
id: match ? match.id : undefined,
|
id: match ? match.id : undefined,
|
||||||
company: selectedProvider,
|
company: selectedProvider,
|
||||||
@@ -343,254 +568,55 @@ export default function Pricing() {
|
|||||||
setFormOpen(true);
|
setFormOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: 'slab',
|
|
||||||
header: <div style={{ paddingLeft: '12px' }}>Weight Slab</div>,
|
|
||||||
width: pixel(180),
|
|
||||||
renderCell: (row) => (
|
|
||||||
<div style={{ paddingLeft: '12px' }}>
|
|
||||||
<Text type="body" weight="semibold">{row.slab}</Text>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
...providerZones.map((zone) => ({
|
|
||||||
key: zone,
|
|
||||||
header: zone,
|
|
||||||
width: proportional(1),
|
|
||||||
align: 'center',
|
|
||||||
renderCell: (row) => {
|
|
||||||
const match = row.rates[zone];
|
|
||||||
const hasRate = !!match;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<TableCell
|
||||||
className="matrix-cell"
|
key={zone}
|
||||||
style={{
|
align="center"
|
||||||
position: 'relative',
|
sx={{
|
||||||
display: 'flex',
|
py: 1.5,
|
||||||
alignItems: 'center',
|
borderBottom: '1px solid',
|
||||||
justifyContent: 'center',
|
borderColor: 'grey.100',
|
||||||
padding: '4px',
|
cursor: isEditMode ? 'pointer' : 'default',
|
||||||
borderRadius: '6px',
|
transition: 'background 0.15s',
|
||||||
width: '100%',
|
'&:hover': isEditMode ? { bgcolor: alpha('#c01227', 0.04) } : {},
|
||||||
boxSizing: 'border-box'
|
}}
|
||||||
|
onClick={isEditMode ? openCellEdit : undefined}
|
||||||
|
>
|
||||||
|
<Tooltip title={isEditMode ? (hasRate ? 'Click to edit rate' : 'Click to add rate') : tooltipText} placement="top" arrow disableHoverListener={!isEditMode && !tooltipText}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
display: 'inline-block',
|
||||||
|
fontWeight: hasRate ? 700 : 400,
|
||||||
|
color: hasRate ? 'success.dark' : (isEditMode ? alpha('#c01227', 0.4) : 'grey.300'),
|
||||||
|
fontSize: hasRate ? '0.95rem' : '0.85rem',
|
||||||
|
bgcolor: hasRate ? alpha('#10b981', 0.08) : 'transparent',
|
||||||
|
px: hasRate ? 1.5 : 0,
|
||||||
|
py: hasRate ? 0.5 : 0,
|
||||||
|
borderRadius: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasRate ? (
|
{rateStr}
|
||||||
<Badge variant="red" label={`₹${match.rate}`} />
|
</Typography>
|
||||||
) : (
|
{isEditMode && (
|
||||||
<Text type="supporting" color="disabled">—</Text>
|
hasRate
|
||||||
|
? <EditOutlinedIcon sx={{ fontSize: 13, color: 'grey.400' }} />
|
||||||
|
: <AddCircleOutlineIcon sx={{ fontSize: 13, color: alpha('#c01227', 0.4) }} />
|
||||||
)}
|
)}
|
||||||
<button
|
</Box>
|
||||||
type="button"
|
</Tooltip>
|
||||||
className="matrix-cell-edit"
|
</TableCell>
|
||||||
onClick={() => openCellEdit(row.slab, zone, match)}
|
|
||||||
aria-label={`Edit rate for ${row.slab} in ${zone}`}
|
|
||||||
title={hasRate ? 'Edit rate' : 'Add rate'}
|
|
||||||
>
|
|
||||||
<Edit size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
];
|
|
||||||
}, [providerZones, selectedProvider, providerUsesServiceType]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
|
||||||
<PageHeader
|
|
||||||
title="Logistics Pricing Board"
|
|
||||||
action={
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'nowrap' }}>
|
|
||||||
<div style={{ flex: '0 1 280px', minWidth: '200px' }}>
|
|
||||||
<Selector
|
|
||||||
label="Active Logistics Provider"
|
|
||||||
isLabelHidden
|
|
||||||
placeholder={companies.length === 0 ? 'Loading providers…' : 'Select provider'}
|
|
||||||
hasSearch
|
|
||||||
hasClear={false}
|
|
||||||
isDisabled={companies.length === 0}
|
|
||||||
options={companies}
|
|
||||||
value={selectedProvider || ''}
|
|
||||||
onChange={(v) => setSelectedProvider(v || '')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: '0 1 260px', minWidth: '180px' }}>
|
|
||||||
<TextInput
|
|
||||||
label="Search weight slabs"
|
|
||||||
isLabelHidden
|
|
||||||
placeholder="Search weight slabs…"
|
|
||||||
startIcon={<Search size={16} />}
|
|
||||||
hasClear
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(v) => setSearchQuery(v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
icon={<Plus size={14} />}
|
|
||||||
onClick={() => { setEditingRecord({ company: selectedProvider, weight_slab: '', zone: '', rate: '' }); setIsCellEdit(false); setFormOpen(true); }}
|
|
||||||
label="Add Rate"
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '32px', marginTop: '24px' }}>
|
|
||||||
|
|
||||||
{/* Collapsible Provider Profile */}
|
|
||||||
{selectedProvider && providerSurveys.length > 0 && (
|
|
||||||
<Card
|
|
||||||
style={{
|
|
||||||
borderRadius: '16px',
|
|
||||||
border: locationsOpen ? '1px solid rgba(192, 18, 39, 0.25)' : '1px solid rgba(5, 54, 89, 0.08)',
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
padding: '0',
|
|
||||||
overflow: 'hidden',
|
|
||||||
boxShadow: '0 10px 30px -5px rgba(0,0,0,0.02), 0 4px 12px -3px rgba(0,0,0,0.02)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', padding: '12px 16px', cursor: 'pointer', justifyContent: 'space-between', flexWrap: 'wrap', gap: '12px' }}
|
|
||||||
onClick={() => setLocationsOpen(!locationsOpen)}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
|
||||||
<div style={{ width: '32px', height: '32px', borderRadius: '8px', backgroundColor: 'rgba(192, 18, 39, 0.08)', color: '#c01227', fontWeight: 800, fontSize: '0.875rem', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
||||||
{(selectedProvider || 'A')[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
|
|
||||||
<Text type="body" weight="semibold">{selectedProvider}</Text>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
||||||
<MapPin size={12} style={{ color: '#94a3b8' }} />
|
|
||||||
<Text type="supporting" color="secondary">{providerSurveys.length} location{providerSurveys.length !== 1 && 's'}</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
label={locationsOpen ? 'Hide coverage map' : 'View coverage map'}
|
|
||||||
tooltip={locationsOpen ? 'Hide coverage map' : 'View coverage map'}
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
icon={locationsOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{locationsOpen && (
|
|
||||||
<div style={{ padding: '16px 24px 24px 24px', backgroundColor: '#f8fafc', borderTop: '1px dashed rgba(5, 54, 89, 0.1)' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '16px', marginBottom: '14px', flexWrap: 'wrap' }}>
|
|
||||||
<div style={{ maxWidth: '400px', flexGrow: 1, minWidth: '240px' }}>
|
|
||||||
<TextInput
|
|
||||||
label="Search locations"
|
|
||||||
isLabelHidden
|
|
||||||
placeholder="Search areas or addresses…"
|
|
||||||
startIcon={<Search size={16} />}
|
|
||||||
hasClear
|
|
||||||
value={locationSearch}
|
|
||||||
onChange={(v) => setLocationSearch(v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
{filteredSurveys.length} of {providerSurveys.length} serviced location{providerSurveys.length !== 1 ? 's' : ''}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredSurveys.length === 0 ? (
|
|
||||||
<Card style={{ borderRadius: '12px', border: '1px solid rgba(5, 54, 89, 0.08)', backgroundColor: '#ffffff' }}>
|
|
||||||
<EmptyState title="No matching locations" caption="Try a different search term." />
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
|
||||||
gap: '12px',
|
|
||||||
maxHeight: '420px',
|
|
||||||
overflowY: 'auto',
|
|
||||||
paddingRight: '4px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filteredSurveys.map((s, idx) => {
|
|
||||||
const title = s.area || s.address || 'Unknown area';
|
|
||||||
const subtitle = s.area && s.address ? s.address : null;
|
|
||||||
const mapQuery = s.plus_code ? `${s.plus_code} ${s.address || ''}`.trim() : (s.address || s.area);
|
|
||||||
const mapUrl = mapQuery ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(mapQuery)}` : undefined;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={s.id ?? idx}
|
|
||||||
style={{
|
|
||||||
borderRadius: '10px',
|
|
||||||
border: '1px solid rgba(5, 54, 89, 0.08)',
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}
|
|
||||||
className={mapUrl ? 'location-card' : ''}
|
|
||||||
>
|
|
||||||
<Item
|
|
||||||
label={title}
|
|
||||||
description={subtitle}
|
|
||||||
labelLines={1}
|
|
||||||
descriptionLines={2}
|
|
||||||
align="start"
|
|
||||||
startContent={<Icon icon={MapPin} size="sm" color="secondary" />}
|
|
||||||
endContent={mapUrl ? <Icon icon="externalLink" size="xsm" color="secondary" /> : undefined}
|
|
||||||
href={mapUrl}
|
|
||||||
target={mapUrl ? '_blank' : undefined}
|
|
||||||
rel={mapUrl ? 'noopener noreferrer' : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</TableRow>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
</Box>
|
||||||
|
|
||||||
{/* Pricing Data Matrix */}
|
|
||||||
<Card style={{ padding: '0', borderRadius: '16px', border: '1px solid rgba(5, 54, 89, 0.08)', backgroundColor: '#ffffff', overflow: 'hidden', boxShadow: '0 10px 30px -5px rgba(0,0,0,0.02), 0 4px 12px -3px rgba(0,0,0,0.02)' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', padding: '20px 24px', borderBottom: '1px solid rgba(5, 54, 89, 0.06)', background: 'linear-gradient(90deg, rgba(192, 18, 39, 0.03) 0%, #ffffff 70%)', flexWrap: 'wrap' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
||||||
<div style={{ width: '40px', height: '40px', borderRadius: '8px', backgroundColor: 'rgba(192, 18, 39, 0.08)', color: '#C01227', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<LayoutGrid size={18} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Heading level={4}>Rate Matrix</Heading>
|
|
||||||
<Text type="supporting" color="secondary">Weight slab × zone pricing grid{selectedProvider ? ` for ${selectedProvider}` : ''}</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{!loading && providerZones.length > 0 && (
|
|
||||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
|
|
||||||
<Badge variant="info" label={`${displaySlabs.length} slab${displaySlabs.length !== 1 ? 's' : ''}`} />
|
|
||||||
<Badge variant="neutral" label={`${providerZones.length} zone${providerZones.length !== 1 ? 's' : ''}`} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px 0' }}>
|
|
||||||
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #C01227', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
|
||||||
</div>
|
|
||||||
) : providerZones.length === 0 ? (
|
|
||||||
<EmptyState title="No pricing data for this provider yet" caption="Add a rate to start building the pricing matrix." />
|
|
||||||
) : displaySlabs.length === 0 ? (
|
|
||||||
<EmptyState title="No weight slabs match your search" caption="Try a different search term." />
|
|
||||||
) : (
|
|
||||||
<Table
|
|
||||||
data={matrixData}
|
|
||||||
columns={matrixColumns}
|
|
||||||
idKey="slab"
|
|
||||||
density="spacious"
|
|
||||||
dividers="grid"
|
|
||||||
hasHover
|
|
||||||
isStriped
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PricingFormDialog
|
<PricingFormDialog
|
||||||
open={formOpen}
|
open={formOpen}
|
||||||
@@ -602,47 +628,6 @@ export default function Pricing() {
|
|||||||
providerColumns={providerZones}
|
providerColumns={providerZones}
|
||||||
providerUsesServiceType={providerUsesServiceType}
|
providerUsesServiceType={providerUsesServiceType}
|
||||||
/>
|
/>
|
||||||
<style>{`
|
</Box>
|
||||||
/* Rate matrix cells: no global "edit mode" toggle anymore — hovering
|
|
||||||
a specific slab/zone cell reveals a small edit button just for
|
|
||||||
that cell, so editing is always inline and per-cell. No background
|
|
||||||
tint on hover, just the icon. */
|
|
||||||
.matrix-cell-edit {
|
|
||||||
position: absolute;
|
|
||||||
right: 4px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid rgba(5, 54, 89, 0.1);
|
|
||||||
background-color: #ffffff;
|
|
||||||
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.12);
|
|
||||||
color: #C01227;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
|
||||||
}
|
|
||||||
.matrix-cell:hover .matrix-cell-edit {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
.matrix-cell-edit:hover {
|
|
||||||
background-color: rgba(192, 18, 39, 0.08) !important;
|
|
||||||
}
|
|
||||||
.location-card {
|
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
}
|
|
||||||
.location-card:hover {
|
|
||||||
border-color: rgba(192, 18, 39, 0.3) !important;
|
|
||||||
box-shadow: 0 4px 12px rgba(192, 18, 39, 0.08);
|
|
||||||
}
|
|
||||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,29 @@
|
|||||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Search,
|
Card, Stack, Button, TextField, InputAdornment, Box, Tabs, Tab, Chip, Link,
|
||||||
Plus,
|
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton,
|
||||||
Edit,
|
TablePagination, Typography, CircularProgress, Alert, Tooltip, Divider, useMediaQuery,
|
||||||
Trash2,
|
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions
|
||||||
Mail,
|
} from '@mui/material';
|
||||||
Phone,
|
import { useTheme } from '@mui/material/styles';
|
||||||
ShieldAlert,
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
Contact,
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
Headphones,
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
Settings,
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||||
User
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||||
} from 'lucide-react';
|
import MailOutlineIcon from '@mui/icons-material/MailOutline';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
||||||
import { Table, proportional, pixel } from '@astryxdesign/core/Table';
|
import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined';
|
||||||
import { AlertDialog } from '@astryxdesign/core/AlertDialog';
|
import BadgeOutlinedIcon from '@mui/icons-material/BadgeOutlined';
|
||||||
import { Badge } from '@astryxdesign/core/Badge';
|
import SupportAgentOutlinedIcon from '@mui/icons-material/SupportAgentOutlined';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import ManageAccountsOutlinedIcon from '@mui/icons-material/ManageAccountsOutlined';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import PersonOutlineOutlinedIcon from '@mui/icons-material/PersonOutlineOutlined';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
||||||
import { Banner } from '@astryxdesign/core/Banner';
|
|
||||||
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
import StatusChip from '@/components/StatusChip';
|
import StatusChip from '@/components/StatusChip';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
|
import UserAvatar from '@/components/UserAvatar';
|
||||||
import TabLabelCount from '@/components/TabLabelCount';
|
import TabLabelCount from '@/components/TabLabelCount';
|
||||||
import { fetchUsers, deleteUser } from '@/utils/apiClient';
|
import { fetchUsers, deleteUser } from '@/utils/apiClient';
|
||||||
import { toUser } from '@/utils/mappers';
|
import { toUser } from '@/utils/mappers';
|
||||||
@@ -33,39 +32,101 @@ import UserFormDialog from './UserFormDialog';
|
|||||||
|
|
||||||
// Per-role accent colour + icon, used for the avatar badge and role chip.
|
// Per-role accent colour + icon, used for the avatar badge and role chip.
|
||||||
const ROLE_META = {
|
const ROLE_META = {
|
||||||
admin: { color: 'primary', icon: ShieldAlert },
|
admin: { color: 'primary', icon: AdminPanelSettingsOutlinedIcon },
|
||||||
manager: { color: 'success', icon: Settings },
|
manager: { color: 'success', icon: ManageAccountsOutlinedIcon },
|
||||||
rep: { color: 'info', icon: Contact },
|
rep: { color: 'info', icon: BadgeOutlinedIcon },
|
||||||
support: { color: 'warning', icon: Headphones }
|
support: { color: 'warning', icon: SupportAgentOutlinedIcon }
|
||||||
};
|
};
|
||||||
const roleMeta = (role) => ROLE_META[String(role || '').toLowerCase()] || { color: 'secondary', icon: User };
|
const roleMeta = (role) => ROLE_META[String(role || '').toLowerCase()] || { color: 'secondary', icon: PersonOutlineOutlinedIcon };
|
||||||
|
|
||||||
// Name + handle only — no avatar (role is already shown in its own column).
|
// Avatar with a small role-coloured badge in the corner.
|
||||||
function UserIdentity({ name, email }) {
|
function UserIdentity({ name, email, role }) {
|
||||||
|
const { color, icon: RoleIcon } = roleMeta(role);
|
||||||
const handle = email ? email.split('@')[0] : '';
|
const handle = email ? email.split('@')[0] : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minWidth: 0 }}>
|
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||||
<div style={{ fontWeight: 600, color: '#1e293b', fontSize: '0.8rem', lineHeight: 1.2 }}>{name}</div>
|
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||||
{handle && <div style={{ fontSize: '0.68rem', color: '#64748b' }}>@{handle}</div>}
|
<UserAvatar name={name} size={40} sx={{ border: '2px solid', borderColor: `${color}.lighter` }} />
|
||||||
</div>
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', right: -3, bottom: -3, width: 18, height: 18, borderRadius: '50%',
|
||||||
|
bgcolor: `${color}.main`, color: '#fff', display: 'inline-flex', alignItems: 'center',
|
||||||
|
justifyContent: 'center', border: '2px solid #fff'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RoleIcon sx={{ fontSize: 11 }} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800', lineHeight: 1.25 }}>{name}</Typography>
|
||||||
|
{handle && <Typography variant="caption" color="text.secondary">@{handle}</Typography>}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ROLE_BADGE_VARIANT = {
|
|
||||||
primary: 'red',
|
|
||||||
success: 'success',
|
|
||||||
info: 'info',
|
|
||||||
warning: 'warning',
|
|
||||||
secondary: 'neutral'
|
|
||||||
};
|
|
||||||
|
|
||||||
function RoleCell({ role }) {
|
function RoleCell({ role }) {
|
||||||
const { color } = roleMeta(role);
|
const { color, icon: RoleIcon } = roleMeta(role);
|
||||||
return <Badge variant={ROLE_BADGE_VARIANT[color] || 'neutral'} label={titleCase(role)} />;
|
return (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<RoleIcon sx={{ fontSize: 15, ml: 0.5 }} />}
|
||||||
|
label={titleCase(role)}
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
bgcolor: `${color === 'secondary' ? 'grey.100' : `${color}.lighter`}`,
|
||||||
|
color: `${color === 'secondary' ? 'grey.700' : `${color}.dark`}`,
|
||||||
|
'& .MuiChip-icon': { color: 'inherit' }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile presentation of a user row — a self-contained card instead of a wide table row.
|
||||||
|
function UserCard({ row, index, onEdit, onDelete }) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 1.75, borderRadius: 3, border: 1, borderColor: 'divider', bgcolor: 'background.paper' }}>
|
||||||
|
<Stack direction="row" spacing={1.25} alignItems="flex-start">
|
||||||
|
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||||
|
<UserIdentity name={row.name} email={row.email} role={row.role} />
|
||||||
|
</Box>
|
||||||
|
<RoleCell role={row.role} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={1} sx={{ mt: 1.75 }}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ minWidth: 0 }}>
|
||||||
|
<MailOutlineIcon sx={{ fontSize: 16, color: 'grey.400', flexShrink: 0 }} />
|
||||||
|
{row.email ? (
|
||||||
|
<Link href={`mailto:${row.email}`} underline="hover" color="text.primary" noWrap sx={{ fontSize: '0.8125rem' }}>
|
||||||
|
{row.email}
|
||||||
|
</Link>
|
||||||
|
) : <Typography variant="body2" color="text.disabled">No email</Typography>}
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<PhoneOutlinedIcon sx={{ fontSize: 16, color: 'grey.400', flexShrink: 0 }} />
|
||||||
|
{row.phone ? (
|
||||||
|
<Link href={`tel:${row.phone}`} underline="hover" color="text.primary" sx={{ fontSize: '0.8125rem' }}>
|
||||||
|
{row.phone}
|
||||||
|
</Link>
|
||||||
|
) : <Typography variant="body2" color="text.disabled">No phone</Typography>}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1.5 }} />
|
||||||
|
<Stack direction="row" spacing={1} justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography variant="caption" color="text.secondary">#{index}</Typography>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button size="small" variant="outlined" startIcon={<EditOutlinedIcon fontSize="small" />} onClick={() => onEdit(row)}>Edit</Button>
|
||||||
|
<Button size="small" variant="outlined" color="error" startIcon={<DeleteOutlineIcon fontSize="small" />} onClick={() => onDelete(row)}>Delete</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TeamUsers() {
|
export default function TeamUsers() {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -79,25 +140,13 @@ export default function TeamUsers() {
|
|||||||
const [toDelete, setToDelete] = useState(null);
|
const [toDelete, setToDelete] = useState(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
// Guards against out-of-order responses (e.g. React StrictMode's double-invoked
|
|
||||||
// effect, or a quick double-click on Refresh) so a stale request can never
|
|
||||||
// clobber a newer one's result.
|
|
||||||
const loadRequestId = useRef(0);
|
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
const requestId = ++loadRequestId.current;
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
fetchUsers()
|
fetchUsers()
|
||||||
.then((points) => {
|
.then((points) => setUsers(points.map(toUser)))
|
||||||
if (loadRequestId.current === requestId) setUsers(points.map(toUser));
|
.catch((e) => setError(e.message || 'Failed to load team users'))
|
||||||
})
|
.finally(() => setLoading(false));
|
||||||
.catch((e) => {
|
|
||||||
if (loadRequestId.current === requestId) setError(e.message || 'Failed to load team users');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (loadRequestId.current === requestId) setLoading(false);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
@@ -128,99 +177,6 @@ export default function TeamUsers() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const paged = filtered.slice(page * rpp, page * rpp + rpp);
|
const paged = filtered.slice(page * rpp, page * rpp + rpp);
|
||||||
const displayRows = useMemo(
|
|
||||||
() => paged.map((row, i) => ({ ...row, _rowNumber: page * rpp + i + 1 })),
|
|
||||||
[paged, page, rpp]
|
|
||||||
);
|
|
||||||
|
|
||||||
const columns = useMemo(() => [
|
|
||||||
{
|
|
||||||
key: '_rowNumber',
|
|
||||||
header: <div style={{ paddingLeft: '24px', paddingTop: '12px' }}>S.No</div>,
|
|
||||||
width: pixel(72),
|
|
||||||
renderCell: (r) => (
|
|
||||||
<div style={{ paddingLeft: '24px' }}>
|
|
||||||
<Text type="supporting" color="secondary">{r._rowNumber}</Text>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'name',
|
|
||||||
header: <div style={{ padding: '12px 16px 0' }}>User</div>,
|
|
||||||
width: proportional(2),
|
|
||||||
renderCell: (r) => (
|
|
||||||
<div style={{ padding: '10px 16px' }}>
|
|
||||||
<UserIdentity name={r.name} email={r.email} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'email',
|
|
||||||
header: <div style={{ padding: '12px 16px 0' }}>Email</div>,
|
|
||||||
width: proportional(2),
|
|
||||||
renderCell: (r) => (
|
|
||||||
<div style={{ padding: '10px 16px' }}>
|
|
||||||
{r.email ? (
|
|
||||||
<a href={`mailto:${r.email}`} style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', textDecoration: 'none' }} className="link-hover">
|
|
||||||
<Mail size={14} style={{ color: '#94a3b8' }} />
|
|
||||||
<Text type="body" size="sm">{r.email}</Text>
|
|
||||||
</a>
|
|
||||||
) : <Text type="supporting" color="disabled">—</Text>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'phone',
|
|
||||||
header: <div style={{ padding: '12px 16px 0' }}>Phone</div>,
|
|
||||||
width: proportional(1),
|
|
||||||
renderCell: (r) => (
|
|
||||||
<div style={{ padding: '10px 16px' }}>
|
|
||||||
{r.phone ? (
|
|
||||||
<a href={`tel:${r.phone}`} style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', textDecoration: 'none' }} className="link-hover">
|
|
||||||
<Phone size={14} style={{ color: '#94a3b8' }} />
|
|
||||||
<Text type="body" size="sm">{r.phone}</Text>
|
|
||||||
</a>
|
|
||||||
) : <Text type="supporting" color="disabled">—</Text>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'role',
|
|
||||||
header: <div style={{ padding: '12px 16px 0' }}>Role</div>,
|
|
||||||
width: pixel(140),
|
|
||||||
renderCell: (r) => (
|
|
||||||
<div style={{ padding: '10px 16px' }}>
|
|
||||||
<RoleCell role={r.role} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
header: <div style={{ paddingRight: '24px', paddingTop: '12px' }}>Actions</div>,
|
|
||||||
width: pixel(120),
|
|
||||||
align: 'end',
|
|
||||||
renderCell: (r) => (
|
|
||||||
<div style={{ display: 'inline-flex', gap: '8px', paddingRight: '24px' }}>
|
|
||||||
<IconButton
|
|
||||||
label={`Edit ${r.name}`}
|
|
||||||
tooltip="Edit user"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
icon={<Edit size={16} />}
|
|
||||||
onClick={() => setDialog({ open: true, mode: 'edit', initial: r })}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
label={`Delete ${r.name}`}
|
|
||||||
tooltip="Delete user"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
icon={<Trash2 size={16} style={{ color: '#dc2626' }} />}
|
|
||||||
onClick={() => setToDelete(r)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
], []);
|
|
||||||
|
|
||||||
const handleSaved = () => { setDialog({ open: false, mode: 'add', initial: null }); load(); };
|
const handleSaved = () => { setDialog({ open: false, mode: 'add', initial: null }); load(); };
|
||||||
|
|
||||||
@@ -238,118 +194,125 @@ export default function TeamUsers() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Team Users"
|
title="App Users"
|
||||||
|
breadcrumbs={[{ label: 'App Users' }]}
|
||||||
action={
|
action={
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'nowrap' }}>
|
<Stack direction="row" spacing={1.5} sx={{ width: { xs: '100%', sm: 'auto' } }}>
|
||||||
<div style={{ flex: '0 1 320px', minWidth: '200px' }}>
|
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={load} disabled={loading} sx={{ flex: { xs: 1, sm: 'none' } }}>Refresh</Button>
|
||||||
<TextInput
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setDialog({ open: true, mode: 'add', initial: null })} sx={{ flex: { xs: 1, sm: 'none' } }}>Add User</Button>
|
||||||
label="Search team users"
|
</Stack>
|
||||||
isLabelHidden
|
|
||||||
placeholder="Search team users…"
|
|
||||||
startIcon={<Search size={16} />}
|
|
||||||
hasClear
|
|
||||||
value={search}
|
|
||||||
onChange={(v) => { setSearch(v); setPage(0); }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
icon={<Plus size={14} />}
|
|
||||||
onClick={() => setDialog({ open: true, mode: 'add', initial: null })}
|
|
||||||
label="Add User"
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card style={{ padding: '0', borderRadius: '16px', border: '1px solid rgba(5, 54, 89, 0.08)', backgroundColor: '#ffffff', overflow: 'hidden', boxShadow: '0 10px 30px -5px rgba(0,0,0,0.02), 0 4px 12px -3px rgba(0,0,0,0.02)' }}>
|
<Card sx={{ overflow: 'hidden' }}>
|
||||||
{/* Tabs */}
|
<Box
|
||||||
<div style={{ display: 'flex', padding: '0 24px', borderBottom: '1px solid rgba(5, 54, 89, 0.06)', overflowX: 'auto', gap: '24px' }}>
|
sx={{
|
||||||
{tabs.map((t, i) => (
|
px: { xs: 1.75, sm: 2.5 }, py: 2, borderBottom: 1, borderColor: 'divider',
|
||||||
<div
|
display: 'flex', alignItems: 'center', gap: 1.5,
|
||||||
key={t.key}
|
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}66 0%, ${theme.palette.background.paper} 70%)`
|
||||||
onClick={() => { setTab(i); setPage(0); }}
|
|
||||||
style={{
|
|
||||||
padding: '16px 0',
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderBottom: tab === i ? '2px solid #C01227' : '2px solid transparent',
|
|
||||||
color: tab === i ? '#C01227' : '#64748b',
|
|
||||||
fontWeight: tab === i ? 600 : 500,
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
transition: 'all 0.2s'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TabLabelCount label={t.label} count={counts[t.key] || 0} active={tab === i} />
|
<Box sx={{ width: 40, height: 40, borderRadius: 2, bgcolor: 'primary.lighter', color: 'primary.main', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
</div>
|
<GroupsOutlinedIcon fontSize="small" />
|
||||||
))}
|
</Box>
|
||||||
</div>
|
<Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.2 }}>App Users Directory</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Manage console members, roles and access</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{error && (
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: { xs: 1.5, sm: 2 } }} alignItems={{ md: 'center' }}>
|
||||||
<div style={{ padding: '20px 24px 0' }}>
|
<TextField
|
||||||
<Banner
|
size="small" placeholder="Search by name, email, phone…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||||
status="error"
|
sx={{ width: { xs: '100%', md: 300 } }}
|
||||||
title={error}
|
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
|
||||||
endContent={<Button label="Retry" variant="ghost" size="sm" onClick={load} />}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
{!loading && (
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{filtered.length} {filtered.length === 1 ? 'user' : 'users'}
|
||||||
|
</Typography>
|
||||||
|
<Chip size="small" label="live · PostgreSQL" sx={{ height: 22, fontSize: '0.7rem', bgcolor: 'success.lighter', color: 'success.dark', fontWeight: 600 }} />
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tabs value={Math.min(tab, tabs.length - 1)} onChange={(_, v) => { setTab(v); setPage(0); }} variant="scrollable" scrollButtons="auto">
|
||||||
|
{tabs.map((t, i) => (
|
||||||
|
<Tab key={t.key} label={<TabLabelCount label={t.label} count={counts[t.key] || 0} active={tab === i} />} />
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ m: 2 }} action={<Button color="inherit" size="small" onClick={load}>Retry</Button>}>{error}</Alert>}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px 0' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
|
||||||
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #C01227', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
|
||||||
</div>
|
|
||||||
) : paged.length === 0 ? (
|
) : paged.length === 0 ? (
|
||||||
<EmptyState title="No team users found" caption="Try a different role or search term, or add a user." />
|
<EmptyState title="No team users found" caption="Try a different role or search term, or add a user." />
|
||||||
|
) : isMobile ? (
|
||||||
|
<Stack spacing={1.25} sx={{ p: { xs: 1.5, sm: 2 } }}>
|
||||||
|
{paged.map((row, i) => (
|
||||||
|
<UserCard
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
index={page * rpp + i + 1}
|
||||||
|
onEdit={(r) => setDialog({ open: true, mode: 'edit', initial: r })}
|
||||||
|
onDelete={(r) => setToDelete(r)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ paddingTop: '8px', paddingBottom: '8px' }}>
|
<TableContainer>
|
||||||
<Table
|
<Table sx={{ minWidth: 800 }}>
|
||||||
data={displayRows}
|
<TableHead>
|
||||||
columns={columns}
|
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', textTransform: 'uppercase', fontSize: '0.72rem', letterSpacing: 0.4 } }}>
|
||||||
idKey="id"
|
<TableCell sx={{ width: 64 }}>S.No</TableCell>
|
||||||
density="compact"
|
<TableCell>User</TableCell>
|
||||||
dividers="rows"
|
<TableCell>Email</TableCell>
|
||||||
hasHover
|
<TableCell>Phone</TableCell>
|
||||||
isStriped
|
<TableCell>Role</TableCell>
|
||||||
textOverflow="truncate"
|
<TableCell align="right">Actions</TableCell>
|
||||||
/>
|
</TableRow>
|
||||||
</div>
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{paged.map((row, i) => (
|
||||||
|
<TableRow key={row.id} hover>
|
||||||
|
<TableCell><Typography variant="body2" color="text.secondary">{page * rpp + i + 1}</Typography></TableCell>
|
||||||
|
<TableCell><UserIdentity name={row.name} email={row.email} role={row.role} /></TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.email ? (
|
||||||
|
<Link href={`mailto:${row.email}`} underline="hover" color="text.primary" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75, fontSize: '0.8125rem' }}>
|
||||||
|
<MailOutlineIcon sx={{ fontSize: 15, color: 'grey.400' }} />{row.email}
|
||||||
|
</Link>
|
||||||
|
) : <Typography variant="body2" color="text.disabled">—</Typography>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.phone ? (
|
||||||
|
<Link href={`tel:${row.phone}`} underline="hover" color="text.primary" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75, fontSize: '0.8125rem' }}>
|
||||||
|
<PhoneOutlinedIcon sx={{ fontSize: 15, color: 'grey.400' }} />{row.phone}
|
||||||
|
</Link>
|
||||||
|
) : <Typography variant="body2" color="text.disabled">—</Typography>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell><RoleCell role={row.role} /></TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="Edit"><IconButton size="small" onClick={() => setDialog({ open: true, mode: 'edit', initial: row })}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||||
|
<Tooltip title="Delete"><IconButton size="small" color="error" onClick={() => setToDelete(row)}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
)}
|
)}
|
||||||
|
<TablePagination
|
||||||
{/* Pagination bar */}
|
component="div" count={filtered.length} page={page} onPageChange={(_, p) => setPage(p)}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', padding: '12px 24px', borderTop: '1px solid #e2e8f0', gap: '20px', flexWrap: 'wrap' }}>
|
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[5, 10, 25]}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.875rem', color: '#475569' }}>
|
|
||||||
<span>Rows per page:</span>
|
|
||||||
<select
|
|
||||||
value={rpp}
|
|
||||||
onChange={(e) => { setRpp(Number(e.target.value)); setPage(0); }}
|
|
||||||
className="rpp-select"
|
|
||||||
>
|
|
||||||
{[5, 10, 25].map((opt) => <option key={opt} value={opt}>{opt}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: '0.875rem', color: '#475569' }}>
|
|
||||||
{filtered.length === 0 ? '0-0' : `${page * rpp + 1}-${Math.min((page + 1) * rpp, filtered.length)}`} of {filtered.length}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<Button
|
|
||||||
label="Prev"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
||||||
isDisabled={page === 0}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
label="Next"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((p) => Math.min(Math.ceil(filtered.length / rpp) - 1, p + 1))}
|
|
||||||
isDisabled={page >= Math.ceil(filtered.length / rpp) - 1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<UserFormDialog
|
<UserFormDialog
|
||||||
@@ -360,22 +323,18 @@ export default function TeamUsers() {
|
|||||||
onSaved={handleSaved}
|
onSaved={handleSaved}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AlertDialog
|
<Dialog open={!!toDelete} onClose={deleting ? undefined : () => setToDelete(null)}>
|
||||||
isOpen={!!toDelete}
|
<DialogTitle>Delete user?</DialogTitle>
|
||||||
onOpenChange={(o) => { if (!o) setToDelete(null); }}
|
<DialogContent>
|
||||||
title="Delete user?"
|
<DialogContentText>
|
||||||
description={toDelete ? `This will permanently remove ${toDelete.name} from the PostgreSQL database. This cannot be undone.` : ''}
|
This will permanently remove <strong>{toDelete?.name}</strong> from the PostgreSQL database. This cannot be undone.
|
||||||
actionLabel="Delete"
|
</DialogContentText>
|
||||||
onAction={confirmDelete}
|
</DialogContent>
|
||||||
isActionLoading={deleting}
|
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||||
/>
|
<Button onClick={() => setToDelete(null)} disabled={deleting}>Cancel</Button>
|
||||||
|
<Button color="error" variant="contained" onClick={confirmDelete} disabled={deleting} startIcon={deleting ? <CircularProgress size={16} color="inherit" /> : null}>Delete</Button>
|
||||||
<style>{`
|
</DialogActions>
|
||||||
.link-hover:hover {
|
</Dialog>
|
||||||
text-decoration: underline;
|
</>
|
||||||
}
|
|
||||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,26 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import {
|
||||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, TextField,
|
||||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
MenuItem, Alert, CircularProgress, IconButton, useMediaQuery
|
||||||
import { FormLayout } from '@astryxdesign/core/FormLayout';
|
} from '@mui/material';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import { useTheme } from '@mui/material/styles';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import { Banner } from '@astryxdesign/core/Banner';
|
|
||||||
|
|
||||||
import { createUser, updateUser } from '@/utils/apiClient';
|
import { createUser, updateUser } from '@/utils/apiClient';
|
||||||
|
|
||||||
const ROLES = ['admin', 'rep', 'manager'];
|
const ROLES = ['admin', 'rep', 'manager'];
|
||||||
|
|
||||||
const EMPTY = { name: '', email: '', phone: '', role: 'rep', pin: '', password: '' };
|
const EMPTY = { name: '', email: '', phone: '', role: 'rep', pin: '', password: '' };
|
||||||
|
|
||||||
const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts);
|
const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts);
|
||||||
const selectorOptions = (opts, v) => withValue(opts, v).map((o) => ({ value: o, label: o.charAt(0).toUpperCase() + o.slice(1) }));
|
|
||||||
|
|
||||||
export default function UserFormDialog({ open, mode, initial, onClose, onSaved }) {
|
export default function UserFormDialog({ open, mode, initial, onClose, onSaved }) {
|
||||||
const isEdit = mode === 'edit';
|
const isEdit = mode === 'edit';
|
||||||
|
const theme = useTheme();
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
const [form, setForm] = useState(EMPTY);
|
const [form, setForm] = useState(EMPTY);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
const set = (k) => (val) => setForm((f) => ({ ...f, [k]: val }));
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setForm({ ...EMPTY, ...(initial || {}) });
|
setForm({ ...EMPTY, ...(initial || {}) });
|
||||||
@@ -30,6 +28,8 @@ export default function UserFormDialog({ open, mode, initial, onClose, onSaved }
|
|||||||
}
|
}
|
||||||
}, [open, initial]);
|
}, [open, initial]);
|
||||||
|
|
||||||
|
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!form.name.trim()) { setError('Name is required.'); return; }
|
if (!form.name.trim()) { setError('Name is required.'); return; }
|
||||||
if (!form.email.trim()) { setError('Email is required.'); return; }
|
if (!form.email.trim()) { setError('Email is required.'); return; }
|
||||||
@@ -61,50 +61,39 @@ export default function UserFormDialog({ open, mode, initial, onClose, onSaved }
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog isOpen={open} onOpenChange={(o) => { if (!o) onClose(); }} width={600} purpose="form">
|
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
|
||||||
<Layout
|
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
header={<DialogHeader title={isEdit ? 'Edit Team User' : 'Add Team User'} onOpenChange={(o) => { if (!o) onClose(); }} />}
|
{isEdit ? 'Edit Team User' : 'Add Team User'}
|
||||||
content={
|
<IconButton onClick={onClose} size="small" disabled={saving}><CloseIcon /></IconButton>
|
||||||
<LayoutContent>
|
</DialogTitle>
|
||||||
<FormLayout>
|
<DialogContent dividers>
|
||||||
{error && <Banner status="error" title={error} />}
|
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||||
|
<Grid container spacing={2} sx={{ mt: 0 }}>
|
||||||
<FormLayout direction="horizontal">
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Name *" value={form.name} onChange={set('name')} /></Grid>
|
||||||
<TextInput label="Name" isRequired value={form.name} onChange={set('name')} />
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Email *" value={form.email} onChange={set('email')} /></Grid>
|
||||||
<TextInput label="Email" type="email" isRequired value={form.email} onChange={set('email')} />
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Phone" value={form.phone} onChange={set('phone')} /></Grid>
|
||||||
</FormLayout>
|
<Grid item xs={12} sm={6}>
|
||||||
<FormLayout direction="horizontal">
|
<TextField select fullWidth size="small" label="Role" value={form.role} onChange={set('role')}>
|
||||||
<TextInput label="Phone" value={form.phone} onChange={set('phone')} />
|
{withValue(ROLES, form.role).map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)}
|
||||||
<Selector label="Role" options={selectorOptions(ROLES, form.role)} value={form.role} onChange={set('role')} />
|
</TextField>
|
||||||
</FormLayout>
|
</Grid>
|
||||||
<FormLayout direction="horizontal">
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="PIN" value={form.pin} onChange={set('pin')} /></Grid>
|
||||||
<TextInput label="PIN" value={form.pin} onChange={set('pin')} />
|
<Grid item xs={12} sm={6}>
|
||||||
<TextInput
|
<TextField
|
||||||
label={isEdit ? 'New Password (optional)' : 'Password'}
|
fullWidth size="small" type="password"
|
||||||
isRequired={!isEdit}
|
label={isEdit ? 'New Password (optional)' : 'Password *'}
|
||||||
type="password"
|
|
||||||
value={form.password}
|
value={form.password}
|
||||||
onChange={set('password')}
|
onChange={set('password')}
|
||||||
/>
|
/>
|
||||||
</FormLayout>
|
</Grid>
|
||||||
</FormLayout>
|
</Grid>
|
||||||
</LayoutContent>
|
</DialogContent>
|
||||||
}
|
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||||
footer={
|
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||||
<LayoutFooter hasDivider>
|
<Button variant="contained" onClick={handleSave} disabled={saving} startIcon={saving ? <CircularProgress size={16} color="inherit" /> : null}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
{isEdit ? 'Save Changes' : 'Create User'}
|
||||||
<Button label="Cancel" variant="ghost" onClick={onClose} isDisabled={saving} />
|
</Button>
|
||||||
<Button
|
</DialogActions>
|
||||||
label={isEdit ? 'Save Changes' : 'Create User'}
|
|
||||||
variant="primary"
|
|
||||||
onClick={handleSave}
|
|
||||||
isDisabled={saving}
|
|
||||||
isLoading={saving}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</LayoutFooter>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { MapPin } from 'lucide-react';
|
import {
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, TextField,
|
||||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
MenuItem, Box, Typography, Divider, Alert, CircularProgress, IconButton, Autocomplete, Snackbar, useMediaQuery
|
||||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
} from '@mui/material';
|
||||||
import { FormLayout } from '@astryxdesign/core/FormLayout';
|
import { useTheme } from '@mui/material/styles';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
import MyLocationIcon from '@mui/icons-material/MyLocation';
|
||||||
import { TextArea } from '@astryxdesign/core/TextArea';
|
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
|
||||||
import { Divider } from '@astryxdesign/core/Divider';
|
|
||||||
import { Banner } from '@astryxdesign/core/Banner';
|
|
||||||
|
|
||||||
import { createClient, updateClient } from '@/utils/apiClient';
|
import { createClient, updateClient } from '@/utils/apiClient';
|
||||||
|
|
||||||
const BUSINESS_TYPES = ['retail', 'wholesale', 'manufacturer', 'distributor', 'services', 'ecommerce', 'other'];
|
const BUSINESS_TYPES = ['retail', 'wholesale', 'manufacturer', 'distributor', 'services', 'ecommerce', 'other'];
|
||||||
@@ -50,17 +45,16 @@ const EMPTY = {
|
|||||||
|
|
||||||
const formatOption = (s) => typeof s === 'string' ? s.charAt(0).toUpperCase() + s.slice(1).replace(/([A-Z])/g, ' $1').trim() : s;
|
const formatOption = (s) => typeof s === 'string' ? s.charAt(0).toUpperCase() + s.slice(1).replace(/([A-Z])/g, ' $1').trim() : s;
|
||||||
const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts);
|
const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts);
|
||||||
const selectorOptions = (opts, v) => withValue(opts, v).map((o) => ({ value: o, label: formatOption(o) }));
|
|
||||||
|
|
||||||
export default function ClientFormDialog({ open, mode, initial, onClose, onSaved }) {
|
export default function ClientFormDialog({ open, mode, initial, onClose, onSaved }) {
|
||||||
const isEdit = mode === 'edit';
|
const isEdit = mode === 'edit';
|
||||||
|
const theme = useTheme();
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
const [form, setForm] = useState(EMPTY);
|
const [form, setForm] = useState(EMPTY);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [gettingLocation, setGettingLocation] = useState(false);
|
const [gettingLocation, setGettingLocation] = useState(false);
|
||||||
|
|
||||||
const set = (k) => (val) => setForm((f) => ({ ...f, [k]: val }));
|
|
||||||
|
|
||||||
const handleGPS = () => {
|
const handleGPS = () => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
alert("Geolocation is not supported by your browser");
|
alert("Geolocation is not supported by your browser");
|
||||||
@@ -108,6 +102,8 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
|
|||||||
}
|
}
|
||||||
}, [open, initial]);
|
}, [open, initial]);
|
||||||
|
|
||||||
|
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!form.name.trim()) { setError('Client name is required.'); return; }
|
if (!form.name.trim()) { setError('Client name is required.'); return; }
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -158,108 +154,131 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog isOpen={open} onOpenChange={(o) => { if (!o) onClose(); }} width={800} maxHeight="90dvh" purpose="form">
|
<>
|
||||||
<Layout
|
<Snackbar
|
||||||
header={<DialogHeader title={isEdit ? 'Edit Client' : 'Add Client'} onOpenChange={(o) => { if (!o) onClose(); }} />}
|
open={!!error}
|
||||||
content={
|
autoHideDuration={6000}
|
||||||
<LayoutContent>
|
onClose={() => setError(null)}
|
||||||
<FormLayout>
|
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
{error && <Banner status="error" title={error} />}
|
sx={{ zIndex: 9999 }}
|
||||||
|
>
|
||||||
|
<Alert onClose={() => setError(null)} severity="error" variant="filled" sx={{ width: '100%', boxShadow: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
|
||||||
<Divider label="Business" />
|
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="md" fullWidth fullScreen={typeof fullScreen !== 'undefined' ? fullScreen : false}>
|
||||||
<FormLayout direction="horizontal">
|
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<TextInput label="Client Name" isRequired value={form.name} onChange={set('name')} />
|
{isEdit ? 'Edit Client' : 'Add Client'}
|
||||||
<TextInput label="Phone" value={form.phone} onChange={set('phone')} />
|
<IconButton onClick={onClose} size="small" disabled={saving}><CloseIcon /></IconButton>
|
||||||
</FormLayout>
|
</DialogTitle>
|
||||||
<FormLayout direction="horizontal">
|
<DialogContent dividers>
|
||||||
<TextInput label="Email" type="email" value={form.email} onChange={set('email')} />
|
<Typography variant="overline" color="text.secondary">Business</Typography>
|
||||||
{!isEdit && <TextInput label="Password" type="password" value={form.password} onChange={set('password')} />}
|
<Grid container spacing={2} sx={{ mt: 0, mb: 1 }}>
|
||||||
</FormLayout>
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Client Name *" value={form.name} onChange={set('name')} /></Grid>
|
||||||
<FormLayout direction="horizontal">
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Phone" value={form.phone} onChange={set('phone')} /></Grid>
|
||||||
<Selector label="Business Type" options={selectorOptions(BUSINESS_TYPES, form.businessType)} value={form.businessType} onChange={set('businessType')} />
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Email" value={form.email} onChange={set('email')} /></Grid>
|
||||||
<Selector label="Status" options={selectorOptions(STATUSES, form.status)} value={form.status} onChange={set('status')} />
|
{!isEdit && (
|
||||||
</FormLayout>
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Password" type="password" value={form.password} onChange={set('password')} /></Grid>
|
||||||
<FormLayout direction="horizontal">
|
)}
|
||||||
<Selector label="Order Frequency" options={selectorOptions(FREQUENCIES, form.frequency)} value={form.frequency} onChange={set('frequency')} />
|
<Grid item xs={12} sm={6}>
|
||||||
<NumberInput label="Parcel Volume" value={Number(form.parcelVolume) || 0} onChange={set('parcelVolume')} />
|
<TextField select fullWidth size="small" label="Business Type" value={form.businessType} onChange={set('businessType')}>
|
||||||
<NumberInput label="Contracts" value={Number(form.activeContracts) || 0} onChange={set('activeContracts')} />
|
{withValue(BUSINESS_TYPES, form.businessType).map((o) => <MenuItem key={o} value={o}>{formatOption(o)}</MenuItem>)}
|
||||||
</FormLayout>
|
</TextField>
|
||||||
<FormLayout direction="horizontal">
|
</Grid>
|
||||||
<div>
|
<Grid item xs={12} sm={6}>
|
||||||
<TextInput label="Current Provider" list="providers-list" value={form.provider || ''} onChange={set('provider')} />
|
<TextField select fullWidth size="small" label="Status" value={form.status} onChange={set('status')}>
|
||||||
<datalist id="providers-list">
|
{withValue(STATUSES, form.status).map((o) => <MenuItem key={o} value={o}>{formatOption(o)}</MenuItem>)}
|
||||||
{PROVIDERS.map((p) => <option key={p} value={p} />)}
|
</TextField>
|
||||||
</datalist>
|
</Grid>
|
||||||
</div>
|
<Grid item xs={12} sm={6}>
|
||||||
<TextInput label="Efficiency" value={form.efficiency} onChange={set('efficiency')} />
|
<TextField select fullWidth size="small" label="Order Frequency" value={form.frequency} onChange={set('frequency')}>
|
||||||
</FormLayout>
|
{withValue(FREQUENCIES, form.frequency).map((o) => <MenuItem key={o} value={o}>{formatOption(o)}</MenuItem>)}
|
||||||
<TextInput label="Logistics Segment" placeholder="First Mile, Last Mile" value={form.logisticsSegment} onChange={set('logisticsSegment')} />
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Parcel Volume" value={form.parcelVolume} onChange={set('parcelVolume')} /></Grid>
|
||||||
|
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Active Contracts" value={form.activeContracts} onChange={set('activeContracts')} /></Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Autocomplete
|
||||||
|
autoHighlight
|
||||||
|
options={PROVIDERS}
|
||||||
|
value={form.provider || null}
|
||||||
|
onChange={(e, newValue) => setForm((f) => ({ ...f, provider: newValue || '' }))}
|
||||||
|
renderInput={(params) => <TextField {...params} fullWidth size="small" label="Current Provider" />}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Efficiency" value={form.efficiency} onChange={set('efficiency')} /></Grid>
|
||||||
|
<Grid item xs={12}><TextField fullWidth size="small" label="Logistics Segment" value={form.logisticsSegment} onChange={set('logisticsSegment')} placeholder="First Mile, Last Mile" /></Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Divider label="Location & Transit" />
|
<Divider sx={{ my: 1.5 }} />
|
||||||
<FormLayout direction="horizontal">
|
<Typography variant="overline" color="text.secondary">Location & Transit</Typography>
|
||||||
<div>
|
<Grid container spacing={2} sx={{ mt: 0, mb: 1 }}>
|
||||||
<TextInput label="City" list="cities-list" value={form.city || ''} onChange={set('city')} />
|
<Grid item xs={12} sm={6}>
|
||||||
<datalist id="cities-list">
|
<Autocomplete
|
||||||
{CITIES.map((c) => <option key={c} value={c} />)}
|
autoHighlight
|
||||||
</datalist>
|
options={CITIES}
|
||||||
</div>
|
value={form.city || null}
|
||||||
<TextInput label="State" value={form.businessState} onChange={set('businessState')} />
|
onChange={(e, newValue) => setForm((f) => ({ ...f, city: newValue || '' }))}
|
||||||
</FormLayout>
|
renderInput={(params) => <TextField {...params} fullWidth size="small" label="City" />}
|
||||||
<FormLayout direction="horizontal">
|
/>
|
||||||
<TextInput label="Pincode" value={form.pincode} onChange={set('pincode')} />
|
</Grid>
|
||||||
<TextInput label="Neighbourhood / Zone" value={form.neighbourhood} onChange={set('neighbourhood')} />
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="State" value={form.businessState} onChange={set('businessState')} /></Grid>
|
||||||
</FormLayout>
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Pincode" value={form.pincode} onChange={set('pincode')} /></Grid>
|
||||||
<FormLayout direction="horizontal">
|
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Neighbourhood / Zone" value={form.neighbourhood} onChange={set('neighbourhood')} /></Grid>
|
||||||
<div>
|
<Grid item xs={12} sm={6}>
|
||||||
<TextInput label="Transit From" list="cities-list-from" value={form.transitFrom || ''} onChange={set('transitFrom')} />
|
<Autocomplete
|
||||||
<datalist id="cities-list-from">
|
autoHighlight
|
||||||
{CITIES.map((c) => <option key={c} value={c} />)}
|
options={CITIES}
|
||||||
</datalist>
|
value={form.transitFrom || null}
|
||||||
</div>
|
onChange={(e, newValue) => setForm((f) => ({ ...f, transitFrom: newValue || '' }))}
|
||||||
<div>
|
renderInput={(params) => <TextField {...params} fullWidth size="small" label="Transit From" />}
|
||||||
<TextInput label="Transit To" list="cities-list-to" value={form.transitTo || ''} onChange={set('transitTo')} />
|
/>
|
||||||
<datalist id="cities-list-to">
|
</Grid>
|
||||||
{CITIES.map((c) => <option key={c} value={c} />)}
|
<Grid item xs={12} sm={6}>
|
||||||
</datalist>
|
<Autocomplete
|
||||||
</div>
|
autoHighlight
|
||||||
</FormLayout>
|
options={CITIES}
|
||||||
<FormLayout direction="horizontal">
|
value={form.transitTo || null}
|
||||||
<NumberInput label="Latitude" value={form.surveyLat === '' ? undefined : Number(form.surveyLat)} onChange={set('surveyLat')} />
|
onChange={(e, newValue) => setForm((f) => ({ ...f, transitTo: newValue || '' }))}
|
||||||
<NumberInput label="Longitude" value={form.surveyLng === '' ? undefined : Number(form.surveyLng)} onChange={set('surveyLng')} />
|
renderInput={(params) => <TextField {...params} fullWidth size="small" label="Transit To" />}
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Latitude" value={form.surveyLat} onChange={set('surveyLat')} /></Grid>
|
||||||
|
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Longitude" value={form.surveyLng} onChange={set('surveyLng')} /></Grid>
|
||||||
|
<Grid item xs={12} sm={6} display="flex" alignItems="center">
|
||||||
<Button
|
<Button
|
||||||
label={gettingLocation ? "Getting GPS..." : "Get GPS Location"}
|
variant="outlined"
|
||||||
variant="secondary"
|
size="small"
|
||||||
icon={gettingLocation ? undefined : <MapPin size={16} />}
|
|
||||||
onClick={handleGPS}
|
onClick={handleGPS}
|
||||||
isDisabled={gettingLocation}
|
disabled={gettingLocation}
|
||||||
style={{ width: '100%', height: '40px' }}
|
startIcon={gettingLocation ? <CircularProgress size={16} /> : <MyLocationIcon />}
|
||||||
/>
|
fullWidth
|
||||||
</div>
|
>
|
||||||
</FormLayout>
|
Get GPS Location
|
||||||
<TextArea label="Survey Address" rows={2} value={form.surveyAddress} onChange={set('surveyAddress')} />
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}><TextField fullWidth size="small" label="Survey Address" value={form.surveyAddress} onChange={set('surveyAddress')} multiline minRows={2} /></Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Divider label="Other" />
|
<Divider sx={{ my: 1.5 }} />
|
||||||
<Selector label="Data Consent" options={selectorOptions(CONSENTS, form.dataConsent)} value={form.dataConsent} onChange={set('dataConsent')} />
|
<Typography variant="overline" color="text.secondary">Other</Typography>
|
||||||
<TextArea label="Notes" rows={2} value={form.notes} onChange={set('notes')} />
|
<Grid container spacing={2} sx={{ mt: 0 }}>
|
||||||
</FormLayout>
|
<Grid item xs={12} sm={6}>
|
||||||
</LayoutContent>
|
<TextField select fullWidth size="small" label="Data Consent" value={form.dataConsent} onChange={set('dataConsent')}>
|
||||||
}
|
{withValue(CONSENTS, form.dataConsent).map((o) => <MenuItem key={o} value={o}>{formatOption(o)}</MenuItem>)}
|
||||||
footer={
|
</TextField>
|
||||||
<LayoutFooter hasDivider>
|
</Grid>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
<Grid item xs={12}><TextField fullWidth size="small" label="Notes" value={form.notes} onChange={set('notes')} multiline minRows={2} /></Grid>
|
||||||
<Button label="Cancel" variant="ghost" onClick={onClose} isDisabled={saving} />
|
</Grid>
|
||||||
<Button
|
</DialogContent>
|
||||||
label={isEdit ? 'Save Changes' : 'Create Client'}
|
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||||
variant="primary"
|
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||||
onClick={handleSave}
|
<Button variant="contained" onClick={handleSave} disabled={saving} startIcon={saving ? <CircularProgress size={16} color="inherit" /> : null}>
|
||||||
isDisabled={saving}
|
{isEdit ? 'Save Changes' : 'Create Client'}
|
||||||
isLoading={saving}
|
</Button>
|
||||||
/>
|
</DialogActions>
|
||||||
</div>
|
|
||||||
</LayoutFooter>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import { useState, useMemo, useEffect, Fragment } from 'react';
|
import { useState, useMemo, useEffect, Fragment } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import {
|
import {
|
||||||
Search,
|
Card, Stack, Button, TextField, InputAdornment, Box, Tabs, Tab, Grid,
|
||||||
Plus,
|
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton,
|
||||||
Edit,
|
TablePagination, Typography, Collapse, CircularProgress, Alert, Tooltip, Chip, Divider, Link,
|
||||||
Trash2,
|
useMediaQuery, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions
|
||||||
ChevronDown,
|
} from '@mui/material';
|
||||||
ChevronUp,
|
import { useTheme } from '@mui/material/styles';
|
||||||
Building2,
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
UserPlus,
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
Package,
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
Handshake,
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||||
Phone,
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||||
MapPin,
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
Truck,
|
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||||
Store,
|
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
|
||||||
Info,
|
import FiberNewOutlinedIcon from '@mui/icons-material/FiberNewOutlined';
|
||||||
StickyNote,
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
ArrowRight,
|
import HandshakeOutlinedIcon from '@mui/icons-material/HandshakeOutlined';
|
||||||
Map,
|
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined';
|
||||||
Contact
|
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
|
||||||
} from 'lucide-react';
|
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableHeaderCell, TableCell } from '@astryxdesign/core/Table';
|
import NotesOutlinedIcon from '@mui/icons-material/NotesOutlined';
|
||||||
import { AlertDialog } from '@astryxdesign/core/AlertDialog';
|
import ArrowRightAltIcon from '@mui/icons-material/ArrowRightAlt';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||||||
|
import BadgeOutlinedIcon from '@mui/icons-material/BadgeOutlined';
|
||||||
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
import StatCard from '@/components/StatCard';
|
import StatCard from '@/components/StatCard';
|
||||||
@@ -39,7 +39,8 @@ import { toClient } from '@/utils/mappers';
|
|||||||
import { titleCase } from '@/utils/format';
|
import { titleCase } from '@/utils/format';
|
||||||
import ClientFormDialog from './ClientFormDialog';
|
import ClientFormDialog from './ClientFormDialog';
|
||||||
|
|
||||||
const consentTone = (v) => ({ full: 'success', basiconly: 'info', none: 'neutral' }[String(v || '').toLowerCase()] || 'neutral');
|
// Map categorical enum values to a semantic palette color.
|
||||||
|
const consentTone = (v) => ({ full: 'success', basiconly: 'info', none: 'default' }[String(v || '').toLowerCase()] || 'default');
|
||||||
const efficiencyTone = (v) => {
|
const efficiencyTone = (v) => {
|
||||||
const k = String(v || '').toLowerCase();
|
const k = String(v || '').toLowerCase();
|
||||||
if (/high|good|excellent/.test(k)) return 'success';
|
if (/high|good|excellent/.test(k)) return 'success';
|
||||||
@@ -48,146 +49,140 @@ const efficiencyTone = (v) => {
|
|||||||
return 'info';
|
return 'info';
|
||||||
};
|
};
|
||||||
|
|
||||||
function Pill({ label, color = 'neutral' }) {
|
const FIELD_LABEL_SX = { textTransform: 'uppercase', letterSpacing: 0.4, fontSize: '0.68rem', fontWeight: 700, color: 'grey.700' };
|
||||||
|
|
||||||
|
// A soft, theme-tinted pill for categorical values. Falls back to a dash placeholder.
|
||||||
|
function Pill({ label, color = 'default' }) {
|
||||||
if (label === undefined || label === null || label === '') {
|
if (label === undefined || label === null || label === '') {
|
||||||
return <span style={{ fontWeight: 500, color: '#94a3b8' }}>—</span>;
|
return <Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.500' }}>—</Typography>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soft badge styling mapping
|
|
||||||
const badgeStyles = {
|
|
||||||
success: { bg: '#E3F6EC', fg: '#00773B' },
|
|
||||||
warning: { bg: '#FFF7E0', fg: '#8A6500' },
|
|
||||||
info: { bg: '#E0F7F8', fg: '#00727B' },
|
|
||||||
error: { bg: '#FEEAE9', fg: '#A82216' },
|
|
||||||
primary: { bg: '#F8E0E3', fg: '#9E0E20' },
|
|
||||||
neutral: { bg: '#f1f5f9', fg: '#475569' }
|
|
||||||
};
|
|
||||||
|
|
||||||
const style = badgeStyles[color] || badgeStyles.neutral;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<Chip
|
||||||
style={{
|
size="small"
|
||||||
display: 'inline-block',
|
label={label}
|
||||||
padding: '2px 8px',
|
sx={{
|
||||||
borderRadius: '9999px',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
backgroundColor: style.bg,
|
...(color === 'default'
|
||||||
color: style.fg
|
? { bgcolor: 'grey.100', color: 'grey.700' }
|
||||||
|
: { bgcolor: `${color}.lighter`, color: `${color}.dark` })
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Label + arbitrary node value (text, pill, or grouped chips).
|
||||||
function Field({ label, children }) {
|
function Field({ label, children }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
<Box>
|
||||||
<span style={{ textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: '0.68rem', fontWeight: 700, color: '#475569' }}>{label}</span>
|
<Typography variant="caption" color="text.secondary" sx={FIELD_LABEL_SX}>{label}</Typography>
|
||||||
<div>{children}</div>
|
<Box sx={{ mt: 0.5 }}>{children}</Box>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReadField({ label, value }) {
|
function ReadField({ label, value }) {
|
||||||
return (
|
return (
|
||||||
<Field label={label}>
|
<Field label={label}>
|
||||||
<span style={{ fontWeight: 500, color: value ? '#1e293b' : '#94a3b8', fontSize: '0.875rem', wordBreak: 'break-word' }}>
|
<Typography variant="body2" sx={{ fontWeight: 500, color: value ? 'grey.800' : 'grey.500', wordBreak: 'break-word' }}>
|
||||||
{value || '—'}
|
{value || '—'}
|
||||||
</span>
|
</Typography>
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A bordered sub-card with a tinted header strip — frames each group of fields.
|
||||||
function SectionCard({ icon: Icon, title, accent = 'primary', children }) {
|
function SectionCard({ icon: Icon, title, accent = 'primary', children }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', borderRadius: '8px', border: '1px solid #e2e8f0', backgroundColor: '#ffffff', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ height: '100%', borderRadius: 2, border: 1, borderColor: 'divider', bgcolor: 'background.paper', overflow: 'hidden' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '8px', padding: '10px 16px', borderBottom: '1px solid #e2e8f0', backgroundColor: 'rgba(192, 18, 39, 0.04)' }}>
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ px: 2, py: 1.25, borderBottom: 1, borderColor: 'divider', bgcolor: `${accent}.lighter`, opacity: 0.999 }}>
|
||||||
<Icon size={16} style={{ color: '#C01227' }} />
|
<Icon sx={{ fontSize: 18, color: `${accent}.main` }} />
|
||||||
<span style={{ fontWeight: 700, color: '#1e293b', fontSize: '0.68rem', letterSpacing: '0.05em', textTransform: 'uppercase' }}>{title}</span>
|
<Typography variant="overline" sx={{ fontWeight: 700, color: 'grey.800', letterSpacing: 0.6, lineHeight: 1 }}>{title}</Typography>
|
||||||
</div>
|
</Stack>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px', padding: '16px' }}>{children}</div>
|
<Stack spacing={1.75} sx={{ p: 2 }}>{children}</Stack>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatTile({ label, value, icon: Icon }) {
|
// Mini KPI tile used inside the Business card for the headline numbers.
|
||||||
|
function StatTile({ label, value, icon: Icon, color = 'primary' }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: 1, padding: '12px', borderRadius: '6px', border: '1px solid #e2e8f0', backgroundColor: '#f8fafc', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<Box sx={{ flex: 1, p: 1.5, borderRadius: 1.5, border: 1, borderColor: 'divider', bgcolor: 'grey.50' }}>
|
||||||
<div style={{ width: '32px', height: '32px', borderRadius: '4px', backgroundColor: 'rgba(192, 18, 39, 0.08)', color: '#C01227', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
<Icon size={16} />
|
<Box sx={{ width: 32, height: 32, borderRadius: 1, bgcolor: `${color}.lighter`, color: `${color}.main`, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
</div>
|
<Icon sx={{ fontSize: 18 }} />
|
||||||
<div>
|
</Box>
|
||||||
<div style={{ fontWeight: 700, color: '#1e293b', fontSize: '1.125rem', lineHeight: 1.1 }}>{value}</div>
|
<Box>
|
||||||
<div style={{ fontSize: '0.75rem', color: '#64748b' }}>{label}</div>
|
<Typography variant="h5" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.1 }}>{value}</Typography>
|
||||||
</div>
|
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||||
</div>
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Metric({ label, value, color = '#1e293b' }) {
|
// Compact metric used inline in the table so key numbers show without expanding.
|
||||||
|
function Metric({ label, value, color = 'grey.800' }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', minWidth: '56px' }}>
|
<Box sx={{ textAlign: 'center', minWidth: 56 }}>
|
||||||
<div style={{ fontWeight: 700, color, fontSize: '0.875rem', lineHeight: 1.2 }}>{value}</div>
|
<Typography variant="body2" sx={{ fontWeight: 700, color, lineHeight: 1.2 }}>{value}</Typography>
|
||||||
<div style={{ color: '#64748b', fontSize: '0.65rem' }}>{label}</div>
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem' }}>{label}</Typography>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The expandable detail panel — shared by the desktop table row and the mobile card.
|
||||||
function ClientDetail({ row, onEdit }) {
|
function ClientDetail({ row, onEdit }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ margin: '16px', borderRadius: '10px', border: '1px solid #e2e8f0', overflow: 'hidden', boxShadow: '0 4px 16px rgba(0,0,0,0.04)', backgroundColor: '#ffffff', fontFamily: 'system-ui, sans-serif' }}>
|
<Box sx={{ m: 2, borderRadius: 2.5, border: 1, borderColor: 'divider', overflow: 'hidden', boxShadow: '0 4px 16px rgba(0,0,0,0.06)' }}>
|
||||||
<div
|
<Stack
|
||||||
style={{
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
display: 'flex',
|
justifyContent="space-between"
|
||||||
flexDirection: 'row',
|
alignItems={{ xs: 'flex-start', sm: 'center' }}
|
||||||
justifyContent: 'space-between',
|
spacing={1.5}
|
||||||
alignItems: 'center',
|
sx={{
|
||||||
padding: '14px 20px',
|
px: 2.5, py: 1.75, borderBottom: 1, borderColor: 'divider',
|
||||||
borderBottom: '1px solid #e2e8f0',
|
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}88 0%, ${theme.palette.background.paper} 75%)`
|
||||||
background: 'linear-gradient(90deg, rgba(192, 18, 39, 0.05) 0%, #ffffff 75%)',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: '12px'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||||
<UserAvatar name={row.name} size={40} />
|
<UserAvatar name={row.name} size={40} />
|
||||||
<div>
|
<Box>
|
||||||
<div style={{ fontWeight: 700, fontSize: '1rem', color: '#1e293b', lineHeight: 1.2 }}>{row.name}</div>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.2 }}>{row.name}</Typography>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px', color: '#64748b', fontSize: '0.75rem' }}>
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ mt: 0.25 }}>
|
||||||
<Contact size={12} />
|
<BadgeOutlinedIcon sx={{ fontSize: 14, color: 'grey.400' }} />
|
||||||
<span>{row.clientId}</span>
|
<Typography variant="caption" color="text.secondary">{row.clientId}</Typography>
|
||||||
</div>
|
</Stack>
|
||||||
</div>
|
</Box>
|
||||||
<StatusChip status={row.status} />
|
<StatusChip status={row.status} sx={{ ml: 0.5 }} />
|
||||||
</div>
|
</Stack>
|
||||||
<Button size="sm" variant="secondary" icon={<Edit size={14} />} onClick={() => onEdit(row)} label="Edit Client" />
|
<Button size="small" variant="contained" startIcon={<EditOutlinedIcon />} onClick={() => onEdit(row)} sx={{ width: { xs: '100%', sm: 'auto' } }}>Edit Client</Button>
|
||||||
</div>
|
</Stack>
|
||||||
|
|
||||||
<div style={{ padding: '20px', backgroundColor: '#f8fafc' }}>
|
<Box sx={{ p: { xs: 1.5, sm: 2.5 }, bgcolor: 'grey.50' }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '20px' }}>
|
<Grid container spacing={2.5} alignItems="stretch">
|
||||||
<SectionCard icon={Store} title="Business" accent="primary">
|
<Grid item xs={12} md={4}>
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
<SectionCard icon={StorefrontOutlinedIcon} title="Business" accent="primary">
|
||||||
<StatTile label="Parcel Volume" value={Number(row.parcelVolume).toLocaleString()} icon={Package} />
|
<Stack direction="row" spacing={1.5}>
|
||||||
<StatTile label="Active Contracts" value={row.activeContracts} icon={Handshake} />
|
<StatTile label="Parcel Volume" value={Number(row.parcelVolume).toLocaleString()} icon={Inventory2OutlinedIcon} color="primary" />
|
||||||
</div>
|
<StatTile label="Active Contracts" value={row.activeContracts} icon={HandshakeOutlinedIcon} color="primary" />
|
||||||
|
</Stack>
|
||||||
<Field label="Client ID">
|
<Field label="Client ID">
|
||||||
<span style={{ fontFamily: 'monospace', fontWeight: 600, color: '#1e293b', fontSize: '0.875rem' }}>{row.clientId}</span>
|
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 600, color: 'grey.800' }}>{row.clientId}</Typography>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Business Type"><Pill label={row.businessType && titleCase(row.businessType)} color="primary" /></Field>
|
<Field label="Business Type"><Pill label={row.businessType && titleCase(row.businessType)} color="primary" /></Field>
|
||||||
<Field label="Order Frequency"><Pill label={row.frequency && titleCase(row.frequency)} color="info" /></Field>
|
<Field label="Order Frequency"><Pill label={row.frequency && titleCase(row.frequency)} color="info" /></Field>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<SectionCard icon={Truck} title="Logistics" accent="primary">
|
<Grid item xs={12} md={4}>
|
||||||
|
<SectionCard icon={LocalShippingOutlinedIcon} title="Logistics" accent="primary">
|
||||||
<Field label="Logistics Segment">
|
<Field label="Logistics Segment">
|
||||||
{row.logisticsSegment ? (
|
{row.logisticsSegment ? (
|
||||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap>
|
||||||
{String(row.logisticsSegment).split(/[,/]/).map((seg) => seg.trim()).filter(Boolean).map((seg) => (
|
{String(row.logisticsSegment).split(/[,/]/).map((seg) => seg.trim()).filter(Boolean).map((seg) => (
|
||||||
<Pill key={seg} label={titleCase(seg)} color="primary" />
|
<Pill key={seg} label={titleCase(seg)} color="primary" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</Stack>
|
||||||
) : <Pill label={null} />}
|
) : <Pill label={null} />}
|
||||||
</Field>
|
</Field>
|
||||||
<ReadField label="Current Provider" value={row.provider} />
|
<ReadField label="Current Provider" value={row.provider} />
|
||||||
@@ -198,190 +193,224 @@ function ClientDetail({ row, onEdit }) {
|
|||||||
.map((s) => s.trim()).filter(Boolean);
|
.map((s) => s.trim()).filter(Boolean);
|
||||||
if (!stops.length) return <Pill label={null} />;
|
if (!stops.length) return <Pill label={null} />;
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap' }}>
|
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||||
{stops.map((stop, idx) => (
|
{stops.map((stop, idx) => (
|
||||||
<Fragment key={`${stop}-${idx}`}>
|
<Fragment key={`${stop}-${idx}`}>
|
||||||
{idx > 0 && <ArrowRight size={14} style={{ color: '#C01227' }} />}
|
{idx > 0 && <ArrowRightAltIcon sx={{ fontSize: 20, color: 'primary.main' }} />}
|
||||||
<Pill label={titleCase(stop)} color={idx === 0 ? 'neutral' : 'info'} />
|
<Pill label={titleCase(stop)} color={idx === 0 ? 'default' : 'info'} />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Stack>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</Field>
|
</Field>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<SectionCard icon={MapPin} title="Location & Survey" accent="primary">
|
<Grid item xs={12} md={4}>
|
||||||
|
<SectionCard icon={PlaceOutlinedIcon} title="Location & Survey" accent="primary">
|
||||||
<Field label="City / State">
|
<Field label="City / State">
|
||||||
<span style={{ fontWeight: 500, color: '#1e293b', fontSize: '0.875rem' }}>{[row.city, row.businessState].filter(Boolean).join(', ') || '—'}</span>
|
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>{[row.city, row.businessState].filter(Boolean).join(', ') || '—'}</Typography>
|
||||||
</Field>
|
</Field>
|
||||||
<ReadField label="Neighbourhood" value={row.neighbourhood} />
|
<ReadField label="Neighbourhood" value={row.neighbourhood} />
|
||||||
<Field label="Survey Address">
|
<Field label="Survey Address">
|
||||||
<div style={{ display: 'flex', gap: '8px', padding: '10px', borderRadius: '6px', backgroundColor: '#f8fafc', border: '1px solid #e2e8f0' }}>
|
<Stack direction="row" spacing={1} sx={{ p: 1.25, borderRadius: 1.5, bgcolor: 'grey.50', border: 1, borderColor: 'divider' }}>
|
||||||
<MapPin size={14} style={{ color: '#94a3b8', marginTop: '2px', flexShrink: 0 }} />
|
<PlaceOutlinedIcon sx={{ fontSize: 16, color: 'grey.400', mt: '2px' }} />
|
||||||
<span style={{ color: '#1e293b', fontSize: '0.875rem', lineHeight: 1.4 }}>{row.surveyAddress || '—'}</span>
|
<Typography variant="body2" sx={{ color: 'grey.800', lineHeight: 1.5 }}>{row.surveyAddress || '—'}</Typography>
|
||||||
</div>
|
</Stack>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Coordinates">
|
<Field label="Coordinates">
|
||||||
{row.surveyLat && row.surveyLng ? (
|
{row.surveyLat && row.surveyLng ? (
|
||||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
|
<Stack direction="row" spacing={1.25} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<Tooltip title={`${row.surveyLat}, ${row.surveyLng}`} arrow placement="top">
|
||||||
<MapPin size={14} style={{ color: '#94a3b8' }} />
|
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ cursor: 'default' }}>
|
||||||
<span style={{ fontWeight: 500, color: '#1e293b', fontSize: '0.875rem', borderBottom: '1px dotted #cbd5e1' }}>
|
<PlaceOutlinedIcon sx={{ fontSize: 16, color: 'grey.400' }} />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800', borderBottom: '1px dotted', borderColor: 'grey.300' }}>
|
||||||
{[row.neighbourhood, row.city].filter(Boolean).join(', ') || 'Pinned location'}
|
{[row.neighbourhood, row.city].filter(Boolean).join(', ') || 'Pinned location'}
|
||||||
</span>
|
</Typography>
|
||||||
</div>
|
</Stack>
|
||||||
<a
|
</Tooltip>
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
href={`https://www.google.com/maps?q=${row.surveyLat},${row.surveyLng}`}
|
href={`https://www.google.com/maps?q=${row.surveyLat},${row.surveyLng}`}
|
||||||
target="_blank" rel="noopener noreferrer"
|
target="_blank" rel="noopener"
|
||||||
style={{
|
size="small" variant="outlined" startIcon={<MapOutlinedIcon sx={{ fontSize: 16 }} />}
|
||||||
display: 'inline-flex',
|
sx={{
|
||||||
alignItems: 'center',
|
py: 0.25, px: 1.25, minWidth: 0, fontSize: '0.75rem', fontWeight: 600, borderRadius: 5,
|
||||||
gap: '4px',
|
color: 'primary.main', borderColor: 'primary.100', bgcolor: 'primary.lighter',
|
||||||
padding: '4px 10px',
|
'&:hover': { borderColor: 'primary.main', bgcolor: 'primary.lighter' }
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
borderRadius: '20px',
|
|
||||||
color: '#C01227',
|
|
||||||
border: '1px solid rgba(192, 18, 39, 0.1)',
|
|
||||||
backgroundColor: 'rgba(192, 18, 39, 0.05)',
|
|
||||||
textDecoration: 'none'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Map size={12} />
|
|
||||||
View on map
|
View on map
|
||||||
</a>
|
</Button>
|
||||||
</div>
|
</Stack>
|
||||||
) : <span style={{ color: '#94a3b8' }}>—</span>}
|
) : <Typography variant="body2" sx={{ color: 'grey.500' }}>—</Typography>}
|
||||||
</Field>
|
</Field>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</div>
|
</Grid>
|
||||||
|
|
||||||
<div style={{ marginTop: '20px' }}>
|
<Grid item xs={12}>
|
||||||
<SectionCard icon={Info} title="Contact & Compliance" accent="primary">
|
<SectionCard icon={InfoOutlinedIcon} title="Contact & Compliance" accent="primary">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
<Grid container spacing={2.5}>
|
||||||
<ReadField label="Phone" value={row.phone} />
|
<Grid item xs={12} sm={6} md={4}><ReadField label="Phone" value={row.phone} /></Grid>
|
||||||
<Field label="Data Consent"><Pill label={row.dataConsent && titleCase(row.dataConsent)} color={consentTone(row.dataConsent)} /></Field>
|
<Grid item xs={12} sm={6} md={4}><Field label="Data Consent"><Pill label={row.dataConsent && titleCase(row.dataConsent)} color={consentTone(row.dataConsent)} /></Field></Grid>
|
||||||
<ReadField label="Last Updated" value={row.lastUpdated} />
|
<Grid item xs={12} sm={6} md={4}><ReadField label="Last Updated" value={row.lastUpdated} /></Grid>
|
||||||
</div>
|
|
||||||
{row.notes && (
|
{row.notes && (
|
||||||
<div style={{ padding: '12px', borderRadius: '6px', backgroundColor: '#fffbeb', border: '1px solid #fef3c7', marginTop: '12px' }}>
|
<Grid item xs={12}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
|
<Box sx={{ p: 2, borderRadius: 1.5, bgcolor: 'warning.lighter', border: 1, borderColor: 'warning.light' }}>
|
||||||
<StickyNote size={14} style={{ color: '#d97706' }} />
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
|
||||||
<span style={{ color: '#d97706', textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: '0.68rem', fontWeight: 700 }}>Notes</span>
|
<NotesOutlinedIcon sx={{ fontSize: 16, color: 'warning.dark' }} />
|
||||||
</div>
|
<Typography variant="caption" sx={{ color: 'warning.dark', textTransform: 'uppercase', letterSpacing: 0.4, fontSize: '0.68rem', fontWeight: 700 }}>Notes</Typography>
|
||||||
<span style={{ color: '#1e293b', fontSize: '0.875rem' }}>{row.notes}</span>
|
</Stack>
|
||||||
</div>
|
<Typography variant="body2" sx={{ color: 'grey.800' }}>{row.notes}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
</Grid>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</div>
|
</Grid>
|
||||||
</div>
|
</Grid>
|
||||||
</div>
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClientRow({ row, onEdit, onDelete }) {
|
// Mobile presentation of a client — summary card with an expandable detail panel.
|
||||||
|
function ClientCard({ row, onEdit, onDelete }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<Box sx={{ borderRadius: 3, border: 1, borderColor: open ? 'primary.light' : 'divider', bgcolor: 'background.paper', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||||
|
<UserAvatar name={row.name} size={42} />
|
||||||
|
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700, color: 'grey.800' }} noWrap>{row.name}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 700, color: 'primary.main' }}>{row.logicalId}</Typography>
|
||||||
|
</Box>
|
||||||
|
<StatusChip status={row.status} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={1} sx={{ mt: 1.75 }}>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<PhoneOutlinedIcon sx={{ fontSize: 16, color: 'grey.400', flexShrink: 0 }} />
|
||||||
|
<Typography variant="body2">{row.phone || '—'}</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<PlaceOutlinedIcon sx={{ fontSize: 16, color: 'grey.400', flexShrink: 0 }} />
|
||||||
|
<Typography variant="body2" noWrap>{row.city || '—'}{row.businessState ? `, ${row.businessState}` : ''}</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1.5} alignItems="center" divider={<Divider orientation="vertical" flexItem />} sx={{ mt: 1.75 }}>
|
||||||
|
<Metric label="Parcels" value={Number(row.parcelVolume).toLocaleString()} />
|
||||||
|
<Metric label="Contracts" value={row.activeContracts} color="primary.main" />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1.5 }} />
|
||||||
|
<Stack direction="row" spacing={1} justifyContent="space-between" alignItems="center">
|
||||||
|
<Button size="small" onClick={() => setOpen((o) => !o)} endIcon={open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}>
|
||||||
|
{open ? 'Hide' : 'Details'}
|
||||||
|
</Button>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button size="small" variant="outlined" startIcon={<EditOutlinedIcon fontSize="small" />} onClick={() => onEdit(row)}>Edit</Button>
|
||||||
|
<Button size="small" variant="outlined" color="error" startIcon={<DeleteOutlineIcon fontSize="small" />} onClick={() => onDelete(row)}>Delete</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ borderTop: 1, borderColor: 'divider' }}>
|
||||||
|
<ClientDetail row={row} onEdit={onEdit} />
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientRow({ row, index, onEdit, onDelete }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<TableRow
|
<TableRow
|
||||||
onClick={() => setOpen((o) => !o)}
|
hover
|
||||||
style={{
|
sx={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
backgroundColor: open ? 'rgba(192, 18, 39, 0.02)' : undefined
|
'& > *': { borderBottom: open ? 'unset' : undefined },
|
||||||
|
...(open && { bgcolor: 'primary.lighter', '&:hover': { bgcolor: 'primary.lighter' } })
|
||||||
}}
|
}}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell padding="checkbox">
|
||||||
<button
|
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setOpen((o) => !o); }}>
|
||||||
onClick={(e) => { e.stopPropagation(); setOpen((o) => !o); }}
|
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', display: 'flex', alignItems: 'center' }}
|
</IconButton>
|
||||||
>
|
|
||||||
{open ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
||||||
</button>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell style={{ whiteSpace: 'nowrap' }}>
|
<TableCell sx={{ whiteSpace: 'nowrap' }}>
|
||||||
<span style={{ fontFamily: 'monospace', fontWeight: 700, color: '#C01227', backgroundColor: 'rgba(192, 18, 39, 0.08)', padding: '2px 8px', borderRadius: '4px', border: '1px solid rgba(192, 18, 39, 0.1)', fontSize: '0.75rem' }}>
|
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 700, color: 'primary.main', bgcolor: 'primary.lighter', px: 1, py: 0.5, borderRadius: 1, border: '1px solid', borderColor: 'primary.light', whiteSpace: 'nowrap' }}>
|
||||||
{row.logicalId}
|
{row.logicalId}
|
||||||
</span>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||||
<UserAvatar name={row.name} size={38} />
|
<UserAvatar name={row.name} size={38} />
|
||||||
<div style={{ minWidth: 0 }}>
|
<Box sx={{ minWidth: 0 }}>
|
||||||
<div style={{ fontWeight: 600, color: '#0f172a', fontSize: '0.875rem' }}>{row.name}</div>
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }}>{row.name}</Typography>
|
||||||
<div style={{ marginTop: '2px' }}>
|
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ mt: 0.4 }}>
|
||||||
{row.businessType && (
|
{row.businessType && (
|
||||||
<span style={{ fontSize: '0.68rem', fontWeight: 600, backgroundColor: '#f1f5f9', color: '#475569', padding: '2px 6px', borderRadius: '4px' }}>
|
<Chip
|
||||||
{titleCase(row.businessType)}
|
size="small"
|
||||||
</span>
|
label={titleCase(row.businessType)}
|
||||||
|
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, bgcolor: 'grey.100', color: 'grey.700' }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Stack>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||||
<Phone size={14} style={{ color: '#94a3b8' }} />
|
<PhoneOutlinedIcon sx={{ fontSize: 15, color: 'grey.400' }} />
|
||||||
<div>
|
<Box>
|
||||||
<div style={{ fontSize: '0.875rem', color: '#0f172a' }}>{row.phone || '—'}</div>
|
<Typography variant="body2">{row.phone || '—'}</Typography>
|
||||||
{row.frequency && <div style={{ fontSize: '0.75rem', color: '#64748b' }}>{row.frequency}</div>}
|
{row.frequency && <Typography variant="caption" color="text.secondary">{row.frequency}</Typography>}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||||
<MapPin size={14} style={{ color: '#94a3b8' }} />
|
<PlaceOutlinedIcon sx={{ fontSize: 15, color: 'grey.400' }} />
|
||||||
<div>
|
<Box>
|
||||||
<div style={{ fontSize: '0.875rem', color: '#0f172a' }}>{row.city || '—'}{row.businessState ? `, ${row.businessState}` : ''}</div>
|
<Typography variant="body2">{row.city || '—'}{row.businessState ? `, ${row.businessState}` : ''}</Typography>
|
||||||
{row.neighbourhood && <div style={{ fontSize: '0.75rem', color: '#64748b' }}>{row.neighbourhood}</div>}
|
{row.neighbourhood && <Typography variant="caption" color="text.secondary">{row.neighbourhood}</Typography>}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Stack>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '12px' }}>
|
<Stack direction="row" spacing={1.5} alignItems="center" justifyContent="center" divider={<Divider orientation="vertical" flexItem />}>
|
||||||
<Metric label="Parcels" value={Number(row.parcelVolume).toLocaleString()} />
|
<Metric label="Parcels" value={Number(row.parcelVolume).toLocaleString()} />
|
||||||
<div style={{ width: '1px', height: '24px', backgroundColor: '#e2e8f0' }} />
|
<Metric label="Contracts" value={row.activeContracts} color="primary.main" />
|
||||||
<Metric label="Contracts" value={row.activeContracts} color="#C01227" />
|
</Stack>
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell><StatusChip status={row.status} /></TableCell>
|
||||||
<StatusChip status={row.status} />
|
<TableCell align="right">
|
||||||
</TableCell>
|
<Tooltip title="Edit"><IconButton size="small" onClick={(e) => { e.stopPropagation(); onEdit(row); }}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||||
<TableCell style={{ textAlign: 'right' }} onClick={(e) => e.stopPropagation()}>
|
<Tooltip title="Delete"><IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(row); }}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>
|
||||||
<div style={{ display: 'inline-flex', gap: '8px' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => onEdit(row)}
|
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '6px', borderRadius: '4px', color: '#475569' }}
|
|
||||||
className="action-btn"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Edit size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onDelete(row)}
|
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '6px', borderRadius: '4px', color: '#dc2626' }}
|
|
||||||
className="action-btn"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{open && (
|
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} style={{ padding: 0, backgroundColor: '#f8fafc' }}>
|
<TableCell colSpan={8} sx={{ py: 0, borderBottom: open ? undefined : 'none' }}>
|
||||||
|
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||||
<ClientDetail row={row} onEdit={onEdit} />
|
<ClientDetail row={row} onEdit={onEdit} />
|
||||||
|
</Collapse>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Tenants() {
|
export default function Tenants() {
|
||||||
const [selectedDate, setSelectedDate] = useState(() => dayjs().format('YYYY-MM-DD'));
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
const [clients, setClients] = useState([]);
|
const [clients, setClients] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -392,6 +421,7 @@ export default function Tenants() {
|
|||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [rpp, setRpp] = useState(10);
|
const [rpp, setRpp] = useState(10);
|
||||||
|
|
||||||
|
// Keep the search box in sync when navigated here with a ?q= query (e.g. from the top search bar).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = searchParams.get('q');
|
const q = searchParams.get('q');
|
||||||
if (q != null) { setSearch(q); setPage(0); }
|
if (q != null) { setSearch(q); setPage(0); }
|
||||||
@@ -419,7 +449,7 @@ export default function Tenants() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
load(true);
|
load(true); // Silent poll every 10 seconds
|
||||||
}, 10000);
|
}, 10000);
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -476,166 +506,117 @@ export default function Tenants() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Clients"
|
title="Clients"
|
||||||
|
breadcrumbs={[{ label: 'Clients' }]}
|
||||||
action={
|
action={
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'nowrap' }}>
|
<Stack direction="row" spacing={1.5} sx={{ width: { xs: '100%', sm: 'auto' } }}>
|
||||||
<div style={{ flex: '0 1 320px', minWidth: '200px' }}>
|
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={load} disabled={loading} sx={{ flex: { xs: 1, sm: 'none' } }}>Refresh</Button>
|
||||||
<TextInput
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setDialog({ open: true, mode: 'add', initial: null })} sx={{ flex: { xs: 1, sm: 'none' } }}>Add Client</Button>
|
||||||
label="Search clients"
|
</Stack>
|
||||||
isLabelHidden
|
|
||||||
placeholder="Search clients…"
|
|
||||||
startIcon={<Search size={16} />}
|
|
||||||
hasClear
|
|
||||||
value={search}
|
|
||||||
onChange={(v) => { setSearch(v); setPage(0); }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className="clients-date-picker"
|
|
||||||
value={selectedDate}
|
|
||||||
onChange={(e) => setSelectedDate(e.target.value)}
|
|
||||||
style={{
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: '10px',
|
|
||||||
border: '1px solid rgba(5, 54, 89, 0.12)',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
color: '#1e293b',
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
outline: 'none',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.04)',
|
|
||||||
flexShrink: 0,
|
|
||||||
width: '150px'
|
|
||||||
}}
|
|
||||||
onFocus={(e) => {
|
|
||||||
e.target.style.borderColor = '#C01227';
|
|
||||||
e.target.style.boxShadow = '0 0 0 3px rgba(192, 18, 39, 0.12)';
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
e.target.style.borderColor = 'rgba(5, 54, 89, 0.12)';
|
|
||||||
e.target.style.boxShadow = '0 1px 2px rgba(15, 23, 42, 0.04)';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
icon={<Plus size={14} />}
|
|
||||||
onClick={() => setDialog({ open: true, mode: 'add', initial: null })}
|
|
||||||
label="Add Client"
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Stats row */}
|
<Grid container spacing={2.5} sx={{ mb: 3 }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Clients" value={stats.total} icon={ApartmentOutlinedIcon} caption="All registered" /></Grid>
|
||||||
<StatCard size="sm" title="Total Clients" value={stats.total} icon={Building2} />
|
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="New Clients" value={stats.newCount} icon={FiberNewOutlinedIcon} color="primary" caption="Awaiting onboarding" /></Grid>
|
||||||
<StatCard size="sm" title="New Clients" value={stats.newCount} icon={UserPlus} color="primary" />
|
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Parcel Volume" value={stats.parcels.toLocaleString()} icon={Inventory2OutlinedIcon} color="primary" caption="Across all clients" /></Grid>
|
||||||
<StatCard size="sm" title="Total Parcel Volume" value={stats.parcels.toLocaleString()} icon={Package} color="primary" />
|
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Active Contracts" value={stats.contracts} icon={HandshakeOutlinedIcon} color="primary" caption="Currently running" /></Grid>
|
||||||
<StatCard size="sm" title="Active Contracts" value={stats.contracts} icon={Handshake} color="primary" />
|
</Grid>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card style={{ padding: '0', borderRadius: '16px', border: '1px solid rgba(5, 54, 89, 0.08)', backgroundColor: '#ffffff', overflow: 'hidden', boxShadow: '0 10px 30px -5px rgba(0,0,0,0.02), 0 4px 12px -3px rgba(0,0,0,0.02)' }}>
|
<Card sx={{ overflow: 'hidden' }}>
|
||||||
|
<Box
|
||||||
{/* Tabs */}
|
sx={{
|
||||||
<div style={{ display: 'flex', padding: '0 24px', borderBottom: '1px solid rgba(5, 54, 89, 0.06)', overflowX: 'auto', gap: '24px' }}>
|
px: 2.5, py: 2, borderBottom: 1, borderColor: 'divider',
|
||||||
{tabs.map((t, i) => (
|
display: 'flex', alignItems: 'center', gap: 1.5,
|
||||||
<div
|
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}66 0%, ${theme.palette.background.paper} 70%)`
|
||||||
key={t.key}
|
|
||||||
onClick={() => { setTab(i); setPage(0); }}
|
|
||||||
style={{
|
|
||||||
padding: '16px 0',
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderBottom: tab === i ? '2px solid #C01227' : '2px solid transparent',
|
|
||||||
color: tab === i ? '#C01227' : '#64748b',
|
|
||||||
fontWeight: tab === i ? 600 : 500,
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
transition: 'all 0.2s'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TabLabelCount label={t.label} count={counts[t.key] || 0} active={tab === i} />
|
<Box sx={{ width: 40, height: 40, borderRadius: 2, bgcolor: 'primary.lighter', color: 'primary.main', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
</div>
|
<ApartmentOutlinedIcon fontSize="small" />
|
||||||
))}
|
</Box>
|
||||||
</div>
|
<Box>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.2 }}>Client Directory</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Browse, search and manage every client account</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{error && (
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
|
||||||
<div style={{ padding: '16px', backgroundColor: '#fef2f2', border: '1px solid #fca5a5', borderRadius: '8px', color: '#b91c1c', margin: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<TextField
|
||||||
<span>{error}</span>
|
size="small" placeholder="Search by name, phone, city, ID…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||||
<Button label="Retry" variant="ghost" size="sm" onClick={() => load(false)} />
|
sx={{ width: { xs: '100%', md: 300 } }}
|
||||||
</div>
|
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ flexGrow: 1 }} />
|
||||||
|
{!loading && (
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{filtered.length} {filtered.length === 1 ? 'client' : 'clients'}
|
||||||
|
</Typography>
|
||||||
|
<Chip size="small" label="live · doormile_clients" sx={{ height: 22, fontSize: '0.7rem', bgcolor: 'success.lighter', color: 'success.dark', fontWeight: 600 }} />
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tabs value={Math.min(tab, tabs.length - 1)} onChange={(_, v) => { setTab(v); setPage(0); }} variant="scrollable" scrollButtons="auto">
|
||||||
|
{tabs.map((t, i) => (
|
||||||
|
<Tab key={t.key} label={<TabLabelCount label={t.label} count={counts[t.key] || 0} active={tab === i} />} />
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ m: 2 }} action={<Button color="inherit" size="small" onClick={load}>Retry</Button>}>{error}</Alert>}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px 0' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
|
||||||
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #C01227', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
|
||||||
</div>
|
|
||||||
) : paged.length === 0 ? (
|
) : paged.length === 0 ? (
|
||||||
<EmptyState title="No clients found" caption="Try a different tab or search term, or add a client." />
|
<EmptyState title="No clients found" caption="Try a different tab or search term, or add a client." />
|
||||||
) : (
|
) : isMobile ? (
|
||||||
<Table density="balanced" dividers="rows" hasHover>
|
<Stack spacing={1.5} sx={{ p: 2 }}>
|
||||||
<TableHeader>
|
|
||||||
<TableRow isHeaderRow>
|
|
||||||
<TableHeaderCell style={{ width: '48px' }} />
|
|
||||||
<TableHeaderCell>ID</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Client</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Contact</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Location</TableHeaderCell>
|
|
||||||
<TableHeaderCell style={{ textAlign: 'center' }}>Volume</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Status</TableHeaderCell>
|
|
||||||
<TableHeaderCell style={{ textAlign: 'right' }}>Actions</TableHeaderCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{paged.map((row) => (
|
{paged.map((row) => (
|
||||||
<ClientRow
|
<ClientCard
|
||||||
key={row.id}
|
key={row.id}
|
||||||
row={row}
|
row={row}
|
||||||
onEdit={(r) => setDialog({ open: true, mode: 'edit', initial: r })}
|
onEdit={(r) => setDialog({ open: true, mode: 'edit', initial: r })}
|
||||||
onDelete={(r) => setToDelete(r)}
|
onDelete={(r) => setToDelete(r)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<TableContainer>
|
||||||
|
<Table sx={{ minWidth: 900 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', textTransform: 'uppercase', fontSize: '0.72rem', letterSpacing: 0.4 } }}>
|
||||||
|
<TableCell padding="checkbox" />
|
||||||
|
<TableCell>ID</TableCell>
|
||||||
|
<TableCell>Client</TableCell>
|
||||||
|
<TableCell>Contact</TableCell>
|
||||||
|
<TableCell>Location</TableCell>
|
||||||
|
<TableCell align="center">Volume</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{paged.map((row, i) => (
|
||||||
|
<ClientRow
|
||||||
|
key={row.id}
|
||||||
|
row={row}
|
||||||
|
index={page * rpp + i}
|
||||||
|
onEdit={(r) => setDialog({ open: true, mode: 'edit', initial: r })}
|
||||||
|
onDelete={(r) => setToDelete(r)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
)}
|
)}
|
||||||
|
<TablePagination
|
||||||
{/* Pagination bar */}
|
component="div" count={filtered.length} page={page} onPageChange={(_, p) => setPage(p)}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', padding: '12px 24px', borderTop: '1px solid #e2e8f0', gap: '20px', flexWrap: 'wrap' }}>
|
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[5, 10, 25]}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.875rem', color: '#475569' }}>
|
|
||||||
<span>Rows per page:</span>
|
|
||||||
<select
|
|
||||||
value={rpp}
|
|
||||||
onChange={(e) => { setRpp(Number(e.target.value)); setPage(0); }}
|
|
||||||
className="rpp-select"
|
|
||||||
>
|
|
||||||
{[5, 10, 25].map((opt) => <option key={opt} value={opt}>{opt}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<span style={{ fontSize: '0.875rem', color: '#475569' }}>
|
|
||||||
{filtered.length === 0 ? '0-0' : `${page * rpp + 1}-${Math.min((page + 1) * rpp, filtered.length)}`} of {filtered.length}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<Button
|
|
||||||
label="Prev"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
||||||
isDisabled={page === 0}
|
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
label="Next"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage((p) => Math.min(Math.ceil(filtered.length / rpp) - 1, p + 1))}
|
|
||||||
isDisabled={page >= Math.ceil(filtered.length / rpp) - 1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<ClientFormDialog
|
<ClientFormDialog
|
||||||
@@ -646,22 +627,18 @@ export default function Tenants() {
|
|||||||
onSaved={handleSaved}
|
onSaved={handleSaved}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AlertDialog
|
<Dialog open={!!toDelete} onClose={deleting ? undefined : () => setToDelete(null)}>
|
||||||
isOpen={!!toDelete}
|
<DialogTitle>Delete client?</DialogTitle>
|
||||||
onOpenChange={(o) => { if (!o) setToDelete(null); }}
|
<DialogContent>
|
||||||
title="Delete client?"
|
<DialogContentText>
|
||||||
description={toDelete ? `This will permanently remove ${toDelete.name} from the doormile_clients collection. This cannot be undone.` : ''}
|
This will permanently remove <strong>{toDelete?.name}</strong> from the doormile_clients collection. This cannot be undone.
|
||||||
actionLabel="Delete"
|
</DialogContentText>
|
||||||
onAction={confirmDelete}
|
</DialogContent>
|
||||||
isActionLoading={deleting}
|
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||||
/>
|
<Button onClick={() => setToDelete(null)} disabled={deleting}>Cancel</Button>
|
||||||
|
<Button color="error" variant="contained" onClick={confirmDelete} disabled={deleting} startIcon={deleting ? <CircularProgress size={16} color="inherit" /> : null}>Delete</Button>
|
||||||
<style>{`
|
</DialogActions>
|
||||||
.action-btn:hover {
|
</Dialog>
|
||||||
background-color: #f1f5f9;
|
</>
|
||||||
}
|
|
||||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
121
src/theme/componentsOverride.js
Normal file
121
src/theme/componentsOverride.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import customShadows from './shadows';
|
||||||
|
|
||||||
|
// ==============================|| DOORMILE THEME - COMPONENT OVERRIDES ||============================== //
|
||||||
|
// Clean, corporate Material Design tuning for the whole console.
|
||||||
|
|
||||||
|
export default function componentsOverride(theme) {
|
||||||
|
const { palette } = theme;
|
||||||
|
|
||||||
|
return {
|
||||||
|
MuiCssBaseline: {
|
||||||
|
styleOverrides: {
|
||||||
|
body: { backgroundColor: palette.background.default },
|
||||||
|
'*::-webkit-scrollbar': { width: 8, height: 8 },
|
||||||
|
'*::-webkit-scrollbar-thumb': { background: palette.grey[300], borderRadius: 8 },
|
||||||
|
'*::-webkit-scrollbar-thumb:hover': { background: palette.grey[400] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiButton: {
|
||||||
|
defaultProps: { disableElevation: true },
|
||||||
|
styleOverrides: {
|
||||||
|
root: { borderRadius: 6, fontWeight: 600, padding: '7px 18px' },
|
||||||
|
containedPrimary: {
|
||||||
|
boxShadow: customShadows.primaryGlow,
|
||||||
|
'&:hover': { boxShadow: customShadows.primaryGlowHover, backgroundColor: palette.primary.dark }
|
||||||
|
},
|
||||||
|
outlined: { borderColor: palette.grey[300] },
|
||||||
|
sizeLarge: { padding: '10px 22px', fontSize: '0.9375rem' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiIconButton: {
|
||||||
|
styleOverrides: { root: { borderRadius: 8 } }
|
||||||
|
},
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 10,
|
||||||
|
border: `1px solid ${palette.grey[200]}`,
|
||||||
|
boxShadow: customShadows.card,
|
||||||
|
backgroundImage: 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiCardHeader: {
|
||||||
|
defaultProps: { titleTypographyProps: { variant: 'h5' }, subheaderTypographyProps: { variant: 'caption' } },
|
||||||
|
styleOverrides: { root: { padding: 20 } }
|
||||||
|
},
|
||||||
|
MuiCardContent: {
|
||||||
|
styleOverrides: { root: { padding: 20, '&:last-child': { paddingBottom: 20 } } }
|
||||||
|
},
|
||||||
|
MuiPaper: {
|
||||||
|
defaultProps: { elevation: 0 },
|
||||||
|
styleOverrides: { rounded: { borderRadius: 10 } }
|
||||||
|
},
|
||||||
|
MuiChip: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: { borderRadius: 6, fontWeight: 600, fontSize: '0.75rem' },
|
||||||
|
sizeSmall: { height: 22 },
|
||||||
|
label: { paddingLeft: 8, paddingRight: 8 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiTableCell: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: { borderColor: palette.grey[200], padding: '12px 16px', fontSize: '0.8125rem' },
|
||||||
|
head: {
|
||||||
|
fontWeight: 600,
|
||||||
|
color: palette.grey[600],
|
||||||
|
backgroundColor: palette.grey[50],
|
||||||
|
textTransform: 'none',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiTableRow: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: { '&:hover': { backgroundColor: palette.primary.lighter + '66' } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiOutlinedInput: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: palette.background.paper,
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': { borderColor: palette.grey[300] },
|
||||||
|
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: palette.grey[400] }
|
||||||
|
},
|
||||||
|
input: { padding: '11px 14px' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiInputLabel: {
|
||||||
|
styleOverrides: { root: { color: palette.grey[600], fontSize: '0.875rem' } }
|
||||||
|
},
|
||||||
|
MuiTab: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: { textTransform: 'none', fontWeight: 600, minHeight: 46, fontSize: '0.875rem' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiTabs: {
|
||||||
|
styleOverrides: { indicator: { height: 3, borderRadius: 3 } }
|
||||||
|
},
|
||||||
|
MuiTooltip: {
|
||||||
|
styleOverrides: {
|
||||||
|
tooltip: { backgroundColor: palette.grey[800], borderRadius: 6, fontSize: '0.75rem', padding: '6px 10px' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiDialog: {
|
||||||
|
styleOverrides: { paper: { borderRadius: 12 } }
|
||||||
|
},
|
||||||
|
MuiAvatar: {
|
||||||
|
styleOverrides: { root: { fontWeight: 600, fontSize: '0.875rem' } }
|
||||||
|
},
|
||||||
|
MuiListItemButton: {
|
||||||
|
styleOverrides: { root: { borderRadius: 8 } }
|
||||||
|
},
|
||||||
|
MuiLinearProgress: {
|
||||||
|
styleOverrides: { root: { borderRadius: 8, height: 6, backgroundColor: palette.grey[200] } }
|
||||||
|
},
|
||||||
|
MuiMenu: {
|
||||||
|
styleOverrides: { paper: { borderRadius: 10, boxShadow: customShadows.dropdown, marginTop: 4 } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
20
src/theme/index.js
Normal file
20
src/theme/index.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { createTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
import palette from './palette';
|
||||||
|
import typography from './typography';
|
||||||
|
import customShadows from './shadows';
|
||||||
|
import componentsOverride from './componentsOverride';
|
||||||
|
|
||||||
|
// ==============================|| DOORMILE THEME - ENTRY ||============================== //
|
||||||
|
|
||||||
|
let theme = createTheme({
|
||||||
|
palette,
|
||||||
|
typography,
|
||||||
|
shape: { borderRadius: 6 },
|
||||||
|
customShadows,
|
||||||
|
mixins: { toolbar: { minHeight: 64 } }
|
||||||
|
});
|
||||||
|
|
||||||
|
theme.components = componentsOverride(theme);
|
||||||
|
|
||||||
|
export default theme;
|
||||||
102
src/theme/palette.js
Normal file
102
src/theme/palette.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// ==============================|| DOORMILE THEME - PALETTE ||============================== //
|
||||||
|
// Corporate red brand palette. Brand red #C01227.
|
||||||
|
|
||||||
|
export const grey = {
|
||||||
|
0: '#FFFFFF',
|
||||||
|
50: '#FAFAFA',
|
||||||
|
100: '#F5F5F5',
|
||||||
|
200: '#F0F0F0',
|
||||||
|
300: '#D9D9D9',
|
||||||
|
400: '#BFBFBF',
|
||||||
|
500: '#8C8C8C',
|
||||||
|
600: '#595959',
|
||||||
|
700: '#434343',
|
||||||
|
800: '#262626',
|
||||||
|
900: '#141414',
|
||||||
|
A50: '#FAFAFB',
|
||||||
|
A100: '#E6EBF1'
|
||||||
|
};
|
||||||
|
|
||||||
|
const palette = {
|
||||||
|
mode: 'light',
|
||||||
|
common: { black: '#000000', white: '#FFFFFF' },
|
||||||
|
primary: {
|
||||||
|
lighter: '#F8E0E3',
|
||||||
|
100: '#EFBBC1',
|
||||||
|
200: '#E08A92',
|
||||||
|
light: '#D6515C',
|
||||||
|
400: '#CC2E3C',
|
||||||
|
main: '#C01227',
|
||||||
|
dark: '#9E0E20',
|
||||||
|
700: '#870C1B',
|
||||||
|
darker: '#7E0B17',
|
||||||
|
900: '#520710',
|
||||||
|
contrastText: '#FFFFFF'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
lighter: grey[100],
|
||||||
|
100: grey[100],
|
||||||
|
200: grey[200],
|
||||||
|
light: grey[300],
|
||||||
|
400: grey[400],
|
||||||
|
main: grey[500],
|
||||||
|
600: grey[600],
|
||||||
|
dark: grey[700],
|
||||||
|
800: grey[800],
|
||||||
|
darker: grey[900],
|
||||||
|
A100: grey[0],
|
||||||
|
A200: grey[400],
|
||||||
|
A300: grey[700],
|
||||||
|
contrastText: grey[0]
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
lighter: '#FEEAE9',
|
||||||
|
light: '#F88078',
|
||||||
|
main: '#F04134',
|
||||||
|
dark: '#A82216',
|
||||||
|
darker: '#7A150C',
|
||||||
|
contrastText: '#FFFFFF'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
lighter: '#FFF7E0',
|
||||||
|
light: '#FFD666',
|
||||||
|
main: '#FFBF00',
|
||||||
|
dark: '#B38600',
|
||||||
|
darker: '#805F00',
|
||||||
|
contrastText: '#262626'
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
lighter: '#E0F7F8',
|
||||||
|
light: '#66CBD2',
|
||||||
|
main: '#00A2AE',
|
||||||
|
dark: '#00727B',
|
||||||
|
darker: '#005159',
|
||||||
|
contrastText: '#FFFFFF'
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
lighter: '#E3F6EC',
|
||||||
|
light: '#5CC98C',
|
||||||
|
main: '#00A854',
|
||||||
|
dark: '#00773B',
|
||||||
|
darker: '#00552A',
|
||||||
|
contrastText: '#FFFFFF'
|
||||||
|
},
|
||||||
|
grey,
|
||||||
|
text: {
|
||||||
|
primary: grey[800],
|
||||||
|
secondary: grey[600],
|
||||||
|
disabled: grey[400]
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
disabled: grey[300],
|
||||||
|
hover: 'rgba(192, 18, 39, 0.04)',
|
||||||
|
selected: 'rgba(192, 18, 39, 0.08)'
|
||||||
|
},
|
||||||
|
divider: grey[200],
|
||||||
|
background: {
|
||||||
|
paper: '#FFFFFF',
|
||||||
|
default: grey.A50
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default palette;
|
||||||
14
src/theme/shadows.js
Normal file
14
src/theme/shadows.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// ==============================|| DOORMILE THEME - CUSTOM SHADOWS ||============================== //
|
||||||
|
// Soft, subtle corporate elevation + a branded red glow for primary CTAs.
|
||||||
|
|
||||||
|
const customShadows = {
|
||||||
|
card: '0px 1px 4px rgba(0, 0, 0, 0.08)',
|
||||||
|
cardHover: '0px 4px 16px rgba(0, 0, 0, 0.10)',
|
||||||
|
widget: '0px 2px 14px rgba(38, 38, 38, 0.06)',
|
||||||
|
dropdown: '0px 8px 24px rgba(38, 38, 38, 0.12)',
|
||||||
|
primaryGlow: '0px 6px 16px rgba(192, 18, 39, 0.28)',
|
||||||
|
primaryGlowHover: '0px 8px 20px rgba(192, 18, 39, 0.36)',
|
||||||
|
header: '0px 1px 0px rgba(0, 0, 0, 0.06)'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default customShadows;
|
||||||
25
src/theme/typography.js
Normal file
25
src/theme/typography.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// ==============================|| DOORMILE THEME - TYPOGRAPHY ||============================== //
|
||||||
|
|
||||||
|
const typography = {
|
||||||
|
fontFamily: '"Public Sans", "Inter", "Helvetica", "Arial", sans-serif',
|
||||||
|
htmlFontSize: 16,
|
||||||
|
fontWeightLight: 300,
|
||||||
|
fontWeightRegular: 400,
|
||||||
|
fontWeightMedium: 500,
|
||||||
|
fontWeightBold: 600,
|
||||||
|
h1: { fontWeight: 700, fontSize: '2.375rem', lineHeight: 1.21 },
|
||||||
|
h2: { fontWeight: 700, fontSize: '1.875rem', lineHeight: 1.27 },
|
||||||
|
h3: { fontWeight: 600, fontSize: '1.5rem', lineHeight: 1.33 },
|
||||||
|
h4: { fontWeight: 600, fontSize: '1.25rem', lineHeight: 1.4 },
|
||||||
|
h5: { fontWeight: 600, fontSize: '1rem', lineHeight: 1.5 },
|
||||||
|
h6: { fontWeight: 500, fontSize: '0.875rem', lineHeight: 1.57 },
|
||||||
|
caption: { fontWeight: 400, fontSize: '0.75rem', lineHeight: 1.66 },
|
||||||
|
body1: { fontSize: '0.875rem', lineHeight: 1.57 },
|
||||||
|
body2: { fontSize: '0.75rem', lineHeight: 1.66 },
|
||||||
|
subtitle1: { fontSize: '0.875rem', fontWeight: 600, lineHeight: 1.57 },
|
||||||
|
subtitle2: { fontSize: '0.75rem', fontWeight: 500, lineHeight: 1.66 },
|
||||||
|
overline: { fontSize: '0.6875rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase' },
|
||||||
|
button: { textTransform: 'capitalize', fontWeight: 600 }
|
||||||
|
};
|
||||||
|
|
||||||
|
export default typography;
|
||||||
@@ -39,7 +39,7 @@ export function toClient(raw) {
|
|||||||
|
|
||||||
export function toUser(raw) {
|
export function toUser(raw) {
|
||||||
const p = raw.payload ?? raw;
|
const p = raw.payload ?? raw;
|
||||||
const id = p.id ?? raw.id ?? p.uid ?? raw.uid ?? p.email;
|
const id = p.id ?? raw.id;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: p.first_name ? `${p.first_name} ${p.last_name || ''}`.trim() : (p.name || '—'),
|
name: p.first_name ? `${p.first_name} ${p.last_name || ''}`.trim() : (p.name || '—'),
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
open: true,
|
open: true,
|
||||||
host: true,
|
|
||||||
// Proxy Qdrant so the api-key stays server-side and the browser avoids CORS.
|
// Proxy Qdrant so the api-key stays server-side and the browser avoids CORS.
|
||||||
// Frontend calls /qdrant/... → forwarded to the Qdrant cluster with the key injected.
|
// Frontend calls /qdrant/... → forwarded to the Qdrant cluster with the key injected.
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user