Compare commits
10 Commits
1b76858c30
...
astryx-des
| Author | SHA1 | Date | |
|---|---|---|---|
| c4b1c580d8 | |||
| 6c70d5e15f | |||
| d4227a0b12 | |||
| 03f07c79ee | |||
| 77ecb83cef | |||
| d2f264460a | |||
| 298fc603a0 | |||
| e8949e0b1a | |||
| 310363347e | |||
| 72a1eb0701 |
BIN
Enquiry.xlsx
Normal file
BIN
Enquiry.xlsx
Normal file
Binary file not shown.
@@ -9,7 +9,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800&family=Inter:wght@400;500;600&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Figtree:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Doormile CRM</title>
|
||||
|
||||
1827
package-lock.json
generated
1827
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -10,15 +10,12 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@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",
|
||||
"@astryxdesign/cli": "^0.1.2",
|
||||
"@astryxdesign/core": "^0.1.2",
|
||||
"@astryxdesign/theme-neutral": "^0.1.2",
|
||||
"dayjs": "^1.11.11",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^6.23.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
BIN
public/navbarLogo.png
Normal file
BIN
public/navbarLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
1
scratch.txt
Normal file
1
scratch.txt
Normal file
@@ -0,0 +1 @@
|
||||
none
|
||||
16
src/App.jsx
16
src/App.jsx
@@ -1,6 +1,5 @@
|
||||
import { Suspense, lazy } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Box, CircularProgress } from '@mui/material';
|
||||
|
||||
import MainLayout from '@/layout/MainLayout';
|
||||
import MinimalLayout from '@/layout/MinimalLayout';
|
||||
@@ -12,9 +11,10 @@ const load = (factory) => {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
|
||||
<CircularProgress color="primary" />
|
||||
</Box>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
|
||||
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #0A1317', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||
<style>{`@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<C />
|
||||
@@ -29,7 +29,13 @@ export default function App() {
|
||||
<Route element={<AuthGuard><MainLayout /></AuthGuard>}>
|
||||
<Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} />
|
||||
|
||||
<Route path="/tenants" element={load(() => import('@/pages/tenants/Tenants'))} />
|
||||
<Route path="/clients" element={load(() => import('@/pages/tenants/Tenants'))} />
|
||||
|
||||
<Route path="/survey" element={load(() => import('@/pages/survey/Survey.jsx'))} />
|
||||
|
||||
<Route path="/pricing" element={load(() => import('@/pages/pricing/Pricing.jsx'))} />
|
||||
|
||||
<Route path="/bookings" element={load(() => import('@/pages/bookings/Bookings'))} />
|
||||
|
||||
<Route path="/team-users" element={load(() => import('@/pages/team/TeamUsers'))} />
|
||||
|
||||
|
||||
808
src/app/dashboard-ref/page.tsx
Normal file
808
src/app/dashboard-ref/page.tsx
Normal file
@@ -0,0 +1,808 @@
|
||||
// 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
285
src/app/login-sso/page.tsx
Normal file
285
src/app/login-sso/page.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
855
src/app/settings-sidebar/page.tsx
Normal file
855
src/app/settings-sidebar/page.tsx
Normal file
@@ -0,0 +1,855 @@
|
||||
// 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
441
src/app/table-page/page.tsx
Normal file
441
src/app/table-page/page.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
// 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,9 +1,40 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Navigate, Outlet, useNavigate } from 'react-router-dom';
|
||||
|
||||
const INACTIVITY_LIMIT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
export default function AuthGuard({ children }) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
|
||||
if (!token) {
|
||||
const navigate = useNavigate();
|
||||
const loggedIn = localStorage.getItem('logged_in');
|
||||
const timerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn) return;
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('logged_in');
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('user_data');
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
const resetTimer = () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(logout, INACTIVITY_LIMIT_MS);
|
||||
};
|
||||
|
||||
const events = ['mousemove', 'keydown', 'scroll', 'click'];
|
||||
|
||||
events.forEach(event => window.addEventListener(event, resetTimer));
|
||||
resetTimer(); // Start the timer initially
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
events.forEach(event => window.removeEventListener(event, resetTimer));
|
||||
};
|
||||
}, [loggedIn, navigate]);
|
||||
|
||||
if (!loggedIn) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +1,13 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import InboxOutlinedIcon from '@mui/icons-material/InboxOutlined';
|
||||
import { EmptyState as AstryxEmptyState } from '@astryxdesign/core/EmptyState';
|
||||
|
||||
// ==============================|| EMPTY STATE ||============================== //
|
||||
|
||||
export default function EmptyState({ icon: Icon = InboxOutlinedIcon, title = 'No records found', caption, sx }) {
|
||||
export default function EmptyState({ icon: Icon, title = 'No records found', caption }) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 6, px: 2, ...sx }}>
|
||||
<Box
|
||||
sx={{
|
||||
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>
|
||||
<AstryxEmptyState
|
||||
title={title}
|
||||
description={caption}
|
||||
icon={Icon ? <Icon size={36} style={{ color: '#94a3b8' }} /> : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,51 +1,74 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
// ==============================|| DOORMILE WORDMARK LOGO ||============================== //
|
||||
// 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
|
||||
// collapsed sidebar) renders just the square "D" badge, since the wordmark won't fit.
|
||||
// collapsed navbar rail) pairs the round navbar mark with a "Doormile" text
|
||||
// wordmark instead, since the full wordmark image is too wide to fit there.
|
||||
|
||||
const LOGO_SRC = '/Doormile-logo.png';
|
||||
const NAVBAR_MARK_SRC = '/navbarLogo.png';
|
||||
|
||||
export default function Logo({ onDark = false, compact = false, height = 26, sx }) {
|
||||
export default function Logo({ onDark = false, compact = false, height = 26, size = 64, style = {} }) {
|
||||
if (compact) {
|
||||
const mark = onDark ? '#FFFFFF' : '#C01227';
|
||||
const markText = onDark ? '#C01227' : '#FFFFFF';
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ...sx }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 2,
|
||||
bgcolor: mark,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 9,
|
||||
// Nudges the mark so its optical centre lines up with the collapsed
|
||||
// sidebar's icon column directly beneath it (measured ~2px apart —
|
||||
// the two live in separate layout trees with their own padding, so
|
||||
// this small correction keeps them reading as one straight column).
|
||||
marginInlineStart: 2,
|
||||
...style
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={NAVBAR_MARK_SRC}
|
||||
// Adjacent text already announces the brand name, so the mark stays
|
||||
// decorative here rather than making screen readers say it twice.
|
||||
alt=""
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
display: 'block',
|
||||
objectFit: 'cover',
|
||||
flexShrink: 0,
|
||||
boxShadow: onDark ? 'none' : '0 4px 10px rgba(192, 18, 39,0.30)'
|
||||
// Matches the mark's original standalone (pre-wordmark) visual
|
||||
// size exactly — scaling only the icon, not the row, keeps the
|
||||
// new "Doormile" text at its own natural size beside it.
|
||||
transform: 'scale(1.4)'
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.01em',
|
||||
lineHeight: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
color: onDark ? '#ffffff' : '#0A1317'
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ color: markText, fontWeight: 800, fontSize: '1.25rem', lineHeight: 1 }}>D</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
Doormile
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', ...sx }}>
|
||||
<Box
|
||||
component="img"
|
||||
<div style={{ display: 'flex', alignItems: 'center', ...style }}>
|
||||
<img
|
||||
src={LOGO_SRC}
|
||||
alt="Doormile"
|
||||
sx={{
|
||||
style={{
|
||||
height,
|
||||
width: 'auto',
|
||||
display: 'block',
|
||||
// The asset is white; on light surfaces recolour it to near-black so it stays visible.
|
||||
filter: onDark ? 'none' : 'brightness(0) saturate(100%)'
|
||||
// The asset is white; on light surfaces recolour it to Doormile Red (#C01227)
|
||||
filter: onDark ? 'none' : 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,24 @@
|
||||
import { Box, Typography, Breadcrumbs, Link, Stack } from '@mui/material';
|
||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
// ==============================|| PAGE HEADER (title + actions) ||============================== //
|
||||
|
||||
// ==============================|| PAGE HEADER (title + breadcrumb + actions) ||============================== //
|
||||
|
||||
export default function PageHeader({ title, breadcrumbs = [], action }) {
|
||||
export default function PageHeader({ title, action }) {
|
||||
return (
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: 'flex-start', sm: 'center' }}
|
||||
spacing={1.5}
|
||||
sx={{ mb: 3 }}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: '12px',
|
||||
marginBottom: '24px'
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="h3" sx={{ fontWeight: 700, color: 'grey.800' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h1 style={{ margin: 0, fontSize: '1.75rem', fontWeight: 700, color: '#1e293b' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{breadcrumbs.length > 0 && (
|
||||
<Breadcrumbs separator={<NavigateNextIcon fontSize="small" />} sx={{ mt: 0.5 }}>
|
||||
<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>
|
||||
</h1>
|
||||
</div>
|
||||
{action && <div style={{ flexShrink: 1, minWidth: 0 }}>{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,83 +1,99 @@
|
||||
import { Card, CardContent, Box, Typography, Avatar, Stack } from '@mui/material';
|
||||
import ArrowUpwardRoundedIcon from '@mui/icons-material/ArrowUpwardRounded';
|
||||
import ArrowDownwardRoundedIcon from '@mui/icons-material/ArrowDownwardRounded';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
|
||||
// ==============================|| STAT / KPI CARD ||============================== //
|
||||
|
||||
export default function StatCard({ title, value, icon: Icon, color = 'primary', trend, caption, accent = false }) {
|
||||
export default function StatCard({ title, value, icon: Icon, color = 'primary', trend, caption, size = 'md' }) {
|
||||
const trendUp = typeof trend === 'number' ? trend >= 0 : null;
|
||||
const isCompact = size === 'sm';
|
||||
|
||||
// Map theme colors to CSS values
|
||||
const colorMap = {
|
||||
primary: { main: '#0A1317', light: 'rgba(10, 19, 23, 0.04)', border: 'rgba(10, 19, 23, 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 (
|
||||
<Card
|
||||
sx={{
|
||||
style={{
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.03)',
|
||||
border: '1px solid rgba(0,0,0,0.04)',
|
||||
borderRadius: isCompact ? '12px' : '16px',
|
||||
padding: isCompact ? '16px' : '24px',
|
||||
border: `1px solid ${themeColor.border}`,
|
||||
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)',
|
||||
'&:hover': { transform: 'translateY(-4px)', boxShadow: '0 16px 32px rgba(0,0,0,0.06)' },
|
||||
background: (theme) => `linear-gradient(145deg, ${theme.palette.background.paper} 0%, ${theme.palette[color].lighter}15 100%)`
|
||||
cursor: 'pointer',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
className="stat-card"
|
||||
>
|
||||
{Icon && (
|
||||
<Box sx={{ position: 'absolute', right: -15, bottom: -15, opacity: 0.04, transform: 'rotate(-15deg)', pointerEvents: 'none' }}>
|
||||
<Icon sx={{ fontSize: 110 }} />
|
||||
</Box>
|
||||
<div style={{ position: 'absolute', right: '-12px', bottom: '-12px', opacity: 0.04, transform: 'rotate(-15deg)', pointerEvents: 'none', color: themeColor.main }}>
|
||||
<Icon size={isCompact ? 64 : 96} />
|
||||
</div>
|
||||
)}
|
||||
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 }, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1.5, position: 'relative', zIndex: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{Icon && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 2,
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: isCompact ? '10px' : '16px', position: 'relative', zIndex: 2 }}>
|
||||
<span style={{ fontWeight: 700, fontSize: '0.75rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{title}
|
||||
</span>
|
||||
{Icon && (
|
||||
<div
|
||||
style={{
|
||||
width: isCompact ? '28px' : '36px',
|
||||
height: isCompact ? '28px' : '36px',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(10, 19, 23, 0.08)',
|
||||
color: '#0A1317'
|
||||
}}
|
||||
>
|
||||
<Icon size={isCompact ? 14 : 18} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flexGrow: 1, position: 'relative', zIndex: 2, marginBottom: (trendUp !== null || caption) ? (isCompact ? '10px' : '16px') : '0' }}>
|
||||
<span style={{ fontWeight: 800, fontSize: isCompact ? '1.5rem' : '2rem', color: '#1e293b', lineHeight: 1, display: 'block' }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(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 }}>
|
||||
{trendUp !== null && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: `${color}.lighter`,
|
||||
color: `${color}.main`,
|
||||
boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.05)'
|
||||
gap: '2px',
|
||||
backgroundColor: trendUp ? '#E3F6EC' : '#FEEAE9',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
</Box>
|
||||
<span style={{ fontSize: '11px', fontWeight: 700, color: trendUp ? '#00773B' : '#A82216' }}>
|
||||
{trendUp ? '↑' : '↓'} {Math.abs(trend)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ flexGrow: 1, position: 'relative', zIndex: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800, color: 'grey.900', letterSpacing: '-0.5px', lineHeight: 1 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{(trendUp !== null || caption) && (
|
||||
<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 && (
|
||||
<Stack direction="row" spacing={0.25} alignItems="center" sx={{ bgcolor: trendUp ? 'success.lighter' : 'error.lighter', px: 0.75, py: 0.25, borderRadius: 1 }}>
|
||||
{trendUp ? (
|
||||
<ArrowUpwardRoundedIcon sx={{ fontSize: 14, color: 'success.dark' }} />
|
||||
) : (
|
||||
<ArrowDownwardRoundedIcon sx={{ fontSize: 14, color: 'error.dark' }} />
|
||||
)}
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: trendUp ? 'success.dark' : 'error.dark' }}>
|
||||
{Math.abs(trend)}%
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
{caption && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>
|
||||
{caption}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
{caption && (
|
||||
<span style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 600 }}>
|
||||
{caption}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,65 +1,56 @@
|
||||
import { Chip } from '@mui/material';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
|
||||
// ==============================|| STATUS CHIP ||============================== //
|
||||
// Soft-filled status chips used across orders, deliveries, riders, invoices.
|
||||
|
||||
const MAP = {
|
||||
// orders / deliveries
|
||||
pending: { color: 'warning', label: 'Pending' },
|
||||
created: { color: 'info', label: 'Created' },
|
||||
assigned: { color: 'info', label: 'Assigned' },
|
||||
accepted: { color: 'info', label: 'Accepted' },
|
||||
arrived: { color: 'info', label: 'Arrived' },
|
||||
picked: { color: 'primary', label: 'Picked' },
|
||||
'in-transit': { color: 'info', label: 'In Transit' },
|
||||
active: { color: 'primary', label: 'Active' },
|
||||
delivered: { color: 'success', label: 'Delivered' },
|
||||
completed: { color: 'success', label: 'Completed' },
|
||||
skipped: { color: 'warning', label: 'Skipped' },
|
||||
failed: { color: 'error', label: 'Failed' },
|
||||
cancelled: { color: 'error', label: 'Cancelled' },
|
||||
pending: { variant: 'warning', label: 'Pending' },
|
||||
created: { variant: 'info', label: 'Created' },
|
||||
assigned: { variant: 'info', label: 'Assigned' },
|
||||
accepted: { variant: 'info', label: 'Accepted' },
|
||||
arrived: { variant: 'info', label: 'Arrived' },
|
||||
picked: { variant: 'red', label: 'Picked' },
|
||||
'in-transit': { variant: 'info', label: 'In Transit' },
|
||||
active: { variant: 'red', label: 'Active' },
|
||||
delivered: { variant: 'success', label: 'Delivered' },
|
||||
completed: { variant: 'success', label: 'Completed' },
|
||||
skipped: { variant: 'warning', label: 'Skipped' },
|
||||
failed: { variant: 'error', label: 'Failed' },
|
||||
cancelled: { variant: 'error', label: 'Cancelled' },
|
||||
'miler-assigned': { variant: 'info', label: 'Miler Assigned' },
|
||||
'pending-pickup': { variant: 'warning', label: 'Pending Pickup' },
|
||||
// clients (doormile_clients)
|
||||
newclient: { color: 'info', label: 'New Client' },
|
||||
contacted: { color: 'warning', label: 'Contacted' },
|
||||
onboarded: { color: 'success', label: 'Onboarded' },
|
||||
lost: { color: 'error', label: 'Lost' },
|
||||
newclient: { variant: 'info', label: 'New Client' },
|
||||
contacted: { variant: 'warning', label: 'Contacted' },
|
||||
onboarded: { variant: 'success', label: 'Onboarded' },
|
||||
lost: { variant: 'error', label: 'Lost' },
|
||||
// team users (doormile_auth)
|
||||
admin: { color: 'primary', label: 'Admin' },
|
||||
rep: { color: 'info', label: 'Rep' },
|
||||
manager: { color: 'success', label: 'Manager' },
|
||||
support: { color: 'warning', label: 'Support' },
|
||||
admin: { variant: 'red', label: 'Admin' },
|
||||
rep: { variant: 'info', label: 'Rep' },
|
||||
manager: { variant: 'success', label: 'Manager' },
|
||||
support: { variant: 'warning', label: 'Support' },
|
||||
// riders / tenants
|
||||
online: { color: 'success', label: 'Online' },
|
||||
offline: { color: 'default', label: 'Offline' },
|
||||
'on-delivery': { color: 'info', label: 'On Delivery' },
|
||||
inactive: { color: 'default', label: 'Inactive' },
|
||||
online: { variant: 'success', label: 'Online' },
|
||||
offline: { variant: 'neutral', label: 'Offline' },
|
||||
'on-delivery': { variant: 'info', label: 'On Delivery' },
|
||||
inactive: { variant: 'neutral', label: 'Inactive' },
|
||||
// invoices
|
||||
paid: { color: 'success', label: 'Paid' },
|
||||
open: { color: 'info', label: 'Open' },
|
||||
overdue: { color: 'error', label: 'Overdue' },
|
||||
prepaid: { color: 'success', label: 'Prepaid' },
|
||||
cod: { color: 'warning', label: 'COD' }
|
||||
paid: { variant: 'success', label: 'Paid' },
|
||||
open: { variant: 'info', label: 'Open' },
|
||||
overdue: { variant: 'error', label: 'Overdue' },
|
||||
prepaid: { variant: 'success', label: 'Prepaid' },
|
||||
cod: { variant: 'warning', label: 'COD' }
|
||||
};
|
||||
|
||||
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 }) {
|
||||
export default function StatusChip({ status, label }) {
|
||||
const key = String(status || '').toLowerCase().replace(/\s+/g, '-');
|
||||
const humanized = String(status || '').replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const cfg = MAP[key] || { color: 'default', label: humanized || status };
|
||||
const tone = TONE[cfg.color] || TONE.default;
|
||||
const cfg = MAP[key] || { variant: 'neutral', label: humanized || status };
|
||||
return (
|
||||
<Chip
|
||||
size={size}
|
||||
<Badge
|
||||
variant={cfg.variant}
|
||||
label={label || cfg.label}
|
||||
sx={{ bgcolor: tone.bg, color: tone.fg, border: 'none', ...sx }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Stack, Box } from '@mui/material';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
|
||||
// ==============================|| TAB LABEL WITH INLINE COUNT PILL ||============================== //
|
||||
// Renders a tab label with the count laid out inline (not an overlapping Badge),
|
||||
@@ -6,25 +6,9 @@ import { Stack, Box } from '@mui/material';
|
||||
|
||||
export default function TabLabelCount({ label, count, active = false }) {
|
||||
return (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '8px' }}>
|
||||
<span>{label}</span>
|
||||
<Box
|
||||
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>
|
||||
<Badge variant={active ? 'red' : 'neutral'} label={count} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
44
src/components/TablePagination.jsx
Normal file
44
src/components/TablePagination.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
|
||||
// ==============================|| SHARED TABLE PAGINATION BAR ||============================== //
|
||||
// Rows-per-page select + "x-y of z" + Prev/Next, used by every list/table page.
|
||||
// `total` is the count of the page's own unit of pagination (rows for most
|
||||
// pages, groups for Survey's company-grouped view) — the caller still owns
|
||||
// its page/rpp state and slicing.
|
||||
|
||||
export default function TablePagination({ page, rpp, total, onPageChange, onRppChange, unitLabel = 'Rows' }) {
|
||||
const lastPage = Math.max(0, Math.ceil(total / rpp) - 1);
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', padding: '12px 24px', borderTop: '1px solid #e2e8f0', gap: '20px', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.875rem', color: '#475569' }}>
|
||||
<span>{unitLabel} per page:</span>
|
||||
<select
|
||||
value={rpp}
|
||||
onChange={(e) => { onRppChange(Number(e.target.value)); onPageChange(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' }}>
|
||||
{total === 0 ? '0-0' : `${page * rpp + 1}-${Math.min((page + 1) * rpp, total)}`} of {total}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Button
|
||||
label="Prev"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(0, page - 1))}
|
||||
isDisabled={page === 0}
|
||||
/>
|
||||
<Button
|
||||
label="Next"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(lastPage, page + 1))}
|
||||
isDisabled={page >= lastPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import { Avatar } from '@mui/material';
|
||||
import { stringToColor, initials } from '@/utils/format';
|
||||
import { Avatar } from '@astryxdesign/core/Avatar';
|
||||
|
||||
// ==============================|| INITIALS AVATAR ||============================== //
|
||||
|
||||
export default function UserAvatar({ name = '', size = 32, sx }) {
|
||||
export default function UserAvatar({ name = '', size = 32 }) {
|
||||
return (
|
||||
<Avatar sx={{ width: size, height: size, bgcolor: stringToColor(name), fontSize: size * 0.42, ...sx }}>
|
||||
{initials(name)}
|
||||
</Avatar>
|
||||
<Avatar name={name} size={size} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
|
||||
// Dependency-free donut chart. data: [{ label, value, color }]
|
||||
export default function DonutChart({ data = [], size = 180, thickness = 26, centerLabel, centerValue }) {
|
||||
const total = data.reduce((s, d) => s + d.value, 0) || 1;
|
||||
@@ -8,14 +6,18 @@ export default function DonutChart({ data = [], size = 180, thickness = 26, cent
|
||||
let offset = 0;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={3}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
sx={{ flexWrap: 'wrap' }}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '24px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
fontFamily: 'system-ui, sans-serif'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative', width: size, maxWidth: '100%', aspectRatio: '1 / 1' }}>
|
||||
<div style={{ position: 'relative', width: `${size}px`, maxWidth: '100%', aspectRatio: '1 / 1' }}>
|
||||
<svg width="100%" height="100%" viewBox={`0 0 ${size} ${size}`}>
|
||||
<g transform={`rotate(-90 ${size / 2} ${size / 2})`}>
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="#F0F0F0" strokeWidth={thickness} />
|
||||
@@ -40,20 +42,20 @@ export default function DonutChart({ data = [], size = 180, thickness = 26, cent
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 700 }}>{centerValue ?? total}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{centerLabel ?? 'Total'}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Stack spacing={1.25}>
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 700, color: '#1e293b' }}>{centerValue ?? total}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: '#64748b' }}>{centerLabel ?? 'Total'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{data.map((d) => (
|
||||
<Stack key={d.label} direction="row" spacing={1} alignItems="center">
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: '3px', bgcolor: d.color }} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 80 }}>{d.label}</Typography>
|
||||
<Typography variant="subtitle2">{d.value.toLocaleString('en-IN')}</Typography>
|
||||
</Stack>
|
||||
<div key={d.label} style={{ display: 'flex', flexDirection: 'row', gap: '8px', alignItems: 'center' }}>
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '3px', backgroundColor: d.color }} />
|
||||
<div style={{ fontSize: '0.875rem', color: '#64748b', minWidth: '80px' }}>{d.label}</div>
|
||||
<div style={{ fontSize: '0.875rem', fontWeight: 600, color: '#1e293b' }}>{d.value.toLocaleString('en-IN')}</div>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
184
src/index.css
Normal file
184
src/index.css
Normal file
@@ -0,0 +1,184 @@
|
||||
/* ─── 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: #0A1317;
|
||||
|
||||
/* Icon tokens */
|
||||
--color-icon-primary: #0A1317;
|
||||
--color-icon-secondary: #4E606F;
|
||||
--color-icon-disabled: #A4B0BC;
|
||||
--color-icon-accent: #0A1317;
|
||||
|
||||
/* 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 — black/white, replaces the theme-neutral default. Hover and
|
||||
active states resolve to this token, so it drives the black hover fills
|
||||
site-wide (see @astryxdesign/core Badge/Button/etc. :hover rules). */
|
||||
--color-accent: #0A1317;
|
||||
--color-accent-muted: rgba(10, 19, 23, 0.12);
|
||||
--color-on-accent: #ffffff;
|
||||
|
||||
/* "red" Badge variant (StatusChip/TabLabelCount) — kept monochrome, matches
|
||||
the neutral tokens so no red renders anywhere in the UI. */
|
||||
--color-background-red: rgba(5, 54, 89, 0.1);
|
||||
--color-text-red: #0A1317;
|
||||
|
||||
/* App-level overrides */
|
||||
background-color: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
/* Use Figtree — the font Astryx neutral theme defines via --font-family-body */
|
||||
*, *::before, *::after {
|
||||
font-family: var(--font-family-body, 'Figtree', -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: #0A1317 !important;
|
||||
box-shadow: 0 0 0 3px rgba(10, 19, 23, 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: #0A1317;
|
||||
box-shadow: 0 0 0 4px rgba(10, 19, 23, 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,219 +1,78 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AppBar,
|
||||
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 { Settings, LogOut } from 'lucide-react';
|
||||
import { TopNav, TopNavHeading } from '@astryxdesign/core/TopNav';
|
||||
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
|
||||
|
||||
import Logo from '@/components/Logo';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { fetchPoints, COLLECTIONS } from '@/utils/qdrant';
|
||||
import { fetchUsers } from '@/utils/apiClient';
|
||||
|
||||
const RED = '#C01227';
|
||||
|
||||
export default function Header({ onToggle }) {
|
||||
export default function Header({ isSidebarCollapsed }) {
|
||||
const navigate = useNavigate();
|
||||
const [account, setAccount] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// Live client search for the top bar.
|
||||
const searchRef = useRef(null);
|
||||
const [clients, setClients] = useState([]);
|
||||
const [loadedClients, setLoadedClients] = useState(false);
|
||||
const [loadingClients, setLoadingClients] = useState(false);
|
||||
const [openResults, setOpenResults] = useState(false);
|
||||
let storedUserObj = { name: 'Admin', role: 'Operations Admin', id: 0 };
|
||||
try {
|
||||
const storedUser = localStorage.getItem('user');
|
||||
if (storedUser) storedUserObj = JSON.parse(storedUser);
|
||||
} catch (e) { }
|
||||
|
||||
const ensureClients = () => {
|
||||
if (loadedClients || loadingClients) return;
|
||||
setLoadingClients(true);
|
||||
fetchPoints(COLLECTIONS.clients)
|
||||
.then((points) => setClients(points.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.payload?.name || '—',
|
||||
city: p.payload?.city || '',
|
||||
businessType: p.payload?.businessType || '',
|
||||
phone: p.payload?.phone || ''
|
||||
}))))
|
||||
.catch(() => {})
|
||||
.finally(() => { setLoadedClients(true); setLoadingClients(false); });
|
||||
};
|
||||
const [activeUserName, setActiveUserName] = useState(storedUserObj.name || 'Admin');
|
||||
|
||||
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);
|
||||
};
|
||||
useEffect(() => {
|
||||
fetchUsers().then((users) => {
|
||||
if (storedUserObj.email) {
|
||||
const matchingUser = users.find(u => u.email === storedUserObj.email);
|
||||
if (matchingUser && matchingUser.first_name) {
|
||||
setActiveUserName(matchingUser.first_name);
|
||||
localStorage.setItem('user', JSON.stringify({ ...storedUserObj, name: matchingUser.first_name }));
|
||||
}
|
||||
}
|
||||
}).catch(console.error);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
position="fixed"
|
||||
elevation={0}
|
||||
sx={{ bgcolor: RED, color: '#fff', zIndex: (t) => t.zIndex.drawer + 1, boxShadow: '0 1px 0 rgba(0,0,0,0.06)' }}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Brand wordmark — left side */}
|
||||
<Box
|
||||
onClick={() => navigate('/dashboard')}
|
||||
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||
>
|
||||
<Logo onDark height={22} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
|
||||
{/* Search — live client lookup */}
|
||||
<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: alpha('#fff', 0.16),
|
||||
borderRadius: 2,
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
width: { sm: 240, md: 320 },
|
||||
'&:hover': { bgcolor: alpha('#fff', 0.22) },
|
||||
'&:focus-within': { bgcolor: alpha('#fff', 0.26) }
|
||||
}}
|
||||
>
|
||||
<SearchIcon sx={{ fontSize: 20, mr: 1, opacity: 0.9 }} />
|
||||
<InputBase
|
||||
value={search}
|
||||
onChange={onSearchChange}
|
||||
onFocus={() => { ensureClients(); if (search.trim()) setOpenResults(true); }}
|
||||
placeholder="Search clients…"
|
||||
sx={{ color: '#fff', fontSize: '0.875rem', flex: 1, '&::placeholder': { color: '#fff' } }}
|
||||
inputProps={{ style: { color: '#fff' }, '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>
|
||||
) : (
|
||||
<>
|
||||
{results.map((c) => (
|
||||
<MenuItem key={c.id} onClick={() => goToClients(c.name)} sx={{ py: 1, gap: 1.25 }}>
|
||||
<UserAvatar name={c.name} size={30} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }} noWrap>{c.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{[c.businessType, c.city].filter(Boolean).join(' · ') || c.phone}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
<Divider />
|
||||
<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()}”
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</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: alpha('#fff', 0.14) } }}
|
||||
>
|
||||
<Avatar sx={{ width: 34, height: 34, bgcolor: '#fff', color: RED, fontWeight: 700 }}>A</Avatar>
|
||||
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: '#fff', fontWeight: 600 }}>
|
||||
Admin
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: alpha('#fff', 0.8) }}>
|
||||
Operations Admin
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Account dropdown */}
|
||||
<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 }}>A</Avatar>
|
||||
<Box sx={{ lineHeight: 1.2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Admin</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Operations Admin</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Divider />
|
||||
<MenuItem onClick={() => { setAccount(null); navigate('/settings'); }}>
|
||||
<ListItemIcon><SettingsOutlinedIcon fontSize="small" /></ListItemIcon>
|
||||
Settings
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={() => { setAccount(null); navigate('/login'); }} sx={{ color: 'error.main' }}>
|
||||
<ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<TopNav
|
||||
label="Main navigation"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderBottom: '1px solid rgba(5, 54, 89, 0.08)',
|
||||
boxShadow: '0 1px 3px rgba(15, 23, 42, 0.04)'
|
||||
}}
|
||||
heading={
|
||||
<TopNavHeading
|
||||
logo={
|
||||
<Logo
|
||||
compact={isSidebarCollapsed}
|
||||
size={isSidebarCollapsed ? 36 : 32}
|
||||
height={28}
|
||||
/>
|
||||
}
|
||||
headingHref="/dashboard"
|
||||
/>
|
||||
}
|
||||
endContent={
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,200 +1,169 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
Box,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Typography,
|
||||
Collapse,
|
||||
Tooltip,
|
||||
Toolbar
|
||||
} 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 { useLocation } from 'react-router-dom';
|
||||
import { SideNav, SideNavSection, SideNavItem } from '@astryxdesign/core/SideNav';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
|
||||
import navItems from '@/menu/navItems';
|
||||
import Logo from '@/components/Logo';
|
||||
|
||||
export const DRAWER_WIDTH = 264;
|
||||
export const MINI_WIDTH = 78;
|
||||
// ==============================|| DOORMILE - SIDE NAV ||============================== //
|
||||
// Thin wrapper around Astryx's SideNav template: sections + items driven by navItems.
|
||||
// Branding lives in the TopNav logo only — a second logo here (above the nav items)
|
||||
// duplicated it, so this stays icon+label only. Collapse state is controlled from
|
||||
// MainLayout so the TopNav logo can track it (round mark collapsed, full wordmark
|
||||
// expanded).
|
||||
|
||||
const RED = '#C01227';
|
||||
|
||||
function NavLeaf({ item, open, active, depth = 0, onClick }) {
|
||||
const Icon = item.icon;
|
||||
const button = (
|
||||
<ListItemButton
|
||||
selected={active}
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
minHeight: 44,
|
||||
my: 0.25,
|
||||
mx: open ? 1 : 0.75,
|
||||
px: open ? 1.5 : 0,
|
||||
justifyContent: open ? 'flex-start' : 'center',
|
||||
borderRadius: 2,
|
||||
color: 'rgba(255,255,255,0.78)',
|
||||
'& .MuiListItemIcon-root': { color: 'inherit' },
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.12)', color: '#fff' },
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'rgba(255,255,255,0.18)',
|
||||
color: '#fff',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.22)' }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: open ? 34 : 'auto', justifyContent: 'center' }}>
|
||||
{depth > 0 && !Icon ? <FiberManualRecordIcon sx={{ fontSize: 8 }} /> : Icon ? <Icon fontSize="small" /> : null}
|
||||
</ListItemIcon>
|
||||
{open && (
|
||||
<ListItemText
|
||||
primary={item.title}
|
||||
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: active ? 700 : 500 }}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
);
|
||||
|
||||
return open ? button : <Tooltip title={item.title} placement="right">{button}</Tooltip>;
|
||||
}
|
||||
|
||||
export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
||||
export default function Sidebar({ isCollapsed, onCollapsedChange }) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isActive = (url) => url && location.pathname.startsWith(url);
|
||||
const expanded = open || isMobile;
|
||||
|
||||
const initialOpen = navItems
|
||||
.flatMap((g) => g.items)
|
||||
.filter((i) => i.children && i.children.some((c) => isActive(c.url)))
|
||||
.map((i) => i.id);
|
||||
const [collapse, setCollapse] = useState(initialOpen);
|
||||
|
||||
const go = (url) => {
|
||||
navigate(url);
|
||||
if (isMobile) onMobileClose();
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Box sx={{ bgcolor: RED, height: '100%', color: '#fff', display: 'flex', flexDirection: 'column' }}>
|
||||
<Toolbar sx={{ px: expanded ? 2.5 : 0, justifyContent: expanded ? 'flex-start' : 'center', minHeight: 64 }}>
|
||||
<Logo onDark 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: 'rgba(255,255,255,0.55)', fontSize: '0.6875rem', letterSpacing: '0.08em' }}
|
||||
>
|
||||
{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)
|
||||
}
|
||||
sx={{
|
||||
minHeight: 44,
|
||||
my: 0.25,
|
||||
mx: expanded ? 1 : 0.75,
|
||||
px: expanded ? 1.5 : 0,
|
||||
justifyContent: expanded ? 'flex-start' : 'center',
|
||||
borderRadius: 2,
|
||||
color: childActive ? '#fff' : 'rgba(255,255,255,0.78)',
|
||||
bgcolor: childActive && !opened ? 'rgba(255,255,255,0.12)' : 'transparent',
|
||||
'&:hover': { bgcolor: 'rgba(255,255,255,0.12)', color: '#fff' }
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: expanded ? 34 : 'auto', justifyContent: 'center', color: 'inherit' }}>
|
||||
<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 rgba(255,255,255,0.12)' }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.55)' }}>
|
||||
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>
|
||||
);
|
||||
}
|
||||
const isActive = (url) => !!url && location.pathname.startsWith(url);
|
||||
|
||||
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 })
|
||||
<>
|
||||
<SideNav
|
||||
className="doormile-side-nav"
|
||||
collapsible={{ isCollapsed, onCollapsedChange, buttonLabel: 'Collapse navigation' }}
|
||||
style={{
|
||||
// White + a right border/shadow matches the TopNav's chrome so the
|
||||
// top and side nav read as one unified surface instead of two
|
||||
// mismatched panels (TopNav is white with a bottom border already).
|
||||
backgroundColor: '#ffffff',
|
||||
borderRight: '1px solid rgba(5, 54, 89, 0.08)',
|
||||
boxShadow: '1px 0 3px rgba(15, 23, 42, 0.03)',
|
||||
paddingBlock: '12px',
|
||||
paddingInline: '8px',
|
||||
boxSizing: 'border-box',
|
||||
// Astryx's collapsed-rail width reads var(--spacing-12) (48px default),
|
||||
// which feels cramped for our icons. Overriding the token on this
|
||||
// element only widens the collapsed rail without touching the same
|
||||
// token's unrelated uses elsewhere (e.g. AppShell's mobile top bar).
|
||||
'--spacing-12': '72px'
|
||||
}}
|
||||
>
|
||||
{navItems.map((grp) => (
|
||||
<SideNavSection key={grp.group} title={grp.group}>
|
||||
{grp.items.map((item) => (
|
||||
<SideNavItem
|
||||
key={item.id}
|
||||
label={item.title}
|
||||
icon={item.icon}
|
||||
href={item.url}
|
||||
isSelected={isActive(item.url)}
|
||||
/>
|
||||
))}
|
||||
</SideNavSection>
|
||||
))}
|
||||
</SideNav>
|
||||
<style>{`
|
||||
/* 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;
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
{content}
|
||||
</Drawer>
|
||||
|
||||
/* Collapsed nav items are the only ones Astryx renders with an
|
||||
aria-label on the item element itself (expanded rows show the
|
||||
label as visible text instead), so it doubles as a stable hook
|
||||
for "collapsed only" styling without touching the expanded rail. */
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label] {
|
||||
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(10, 19, 23, 0.08) !important;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label]:focus-visible {
|
||||
outline: 2px solid rgba(10, 19, 23, 0.4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label][data-selected='selected'] {
|
||||
background-color: rgba(10, 19, 23, 0.12) !important;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label][data-selected='selected'] .astryx-icon {
|
||||
color: #0A1317 !important;
|
||||
}
|
||||
|
||||
/* Expanded rows: rounded hover/selected states with a left accent
|
||||
bar on the active item, instead of the default flat highlight.
|
||||
The accent bar is always present (via ::before) but only faded
|
||||
in on selection, so clicking a link fades it in smoothly instead
|
||||
of the bar snapping into place flush against the row edges.
|
||||
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: 12px !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: 12px;
|
||||
bottom: 12px;
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
background-color: #0A1317;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label]):hover {
|
||||
background-color: rgba(10, 19, 23, 0.05) !important;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label]):focus-visible {
|
||||
outline: 2px solid rgba(10, 19, 23, 0.4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected'] {
|
||||
background-color: rgba(10, 19, 23, 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: #0A1317 !important;
|
||||
}
|
||||
|
||||
/* Astryx's sticky-bottom footer wrapper (the toggle button's direct
|
||||
parent, always the nav's last direct child) drops its top padding
|
||||
to 0 in the collapsed state while keeping 8px on the bottom, so
|
||||
the arrow sits flush against the top of its box instead of
|
||||
centered in the rail's height. Restore matching padding on both
|
||||
sides so it centers correctly. */
|
||||
.doormile-side-nav > div:last-child {
|
||||
padding-block: 8px !important;
|
||||
}
|
||||
|
||||
/* Collapse/expand toggle (the "<" / ">" chevron button). Give it a
|
||||
clean circular hover, centered on the same axis as the nav icons
|
||||
above it (the rail's own 8px inline padding already keeps it clear
|
||||
of the sidebar's edge, so no extra offset is needed here). */
|
||||
.doormile-side-nav button[aria-label*="sidebar"],
|
||||
.doormile-side-nav button[aria-label*="navigation"] {
|
||||
border-radius: 50% !important;
|
||||
transition: background-color 0.15s ease !important;
|
||||
}
|
||||
.doormile-side-nav button[aria-label*="sidebar"]:hover,
|
||||
.doormile-side-nav button[aria-label*="navigation"]:hover {
|
||||
background-color: rgba(10, 19, 23, 0.08) !important;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Box, Toolbar, useMediaQuery } from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { AppShell } from '@astryxdesign/core/AppShell';
|
||||
|
||||
import Header from './Header';
|
||||
import Sidebar, { DRAWER_WIDTH, MINI_WIDTH } from './Sidebar';
|
||||
import Sidebar 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() {
|
||||
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);
|
||||
};
|
||||
// Lifted here (rather than left as SideNav's own uncontrolled state) so the
|
||||
// navbar's logo can track the sidebar's collapse state — one logo, not two.
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(true);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', bgcolor: 'background.default', minHeight: '100vh' }}>
|
||||
<Header onToggle={toggle} />
|
||||
<Sidebar
|
||||
open={open}
|
||||
isMobile={isMobile}
|
||||
mobileOpen={mobileOpen}
|
||||
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 })
|
||||
}}
|
||||
>
|
||||
<Toolbar sx={{ minHeight: 64 }} />
|
||||
<Box sx={{ p: { xs: 2, sm: 3 } }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<AppShell
|
||||
variant="section"
|
||||
height="fill"
|
||||
contentPadding={0}
|
||||
topNav={<Header isSidebarCollapsed={isSidebarCollapsed} />}
|
||||
sideNav={<Sidebar isCollapsed={isSidebarCollapsed} onCollapsedChange={setIsSidebarCollapsed} />}
|
||||
mobileNav={{ breakpoint: 'lg' }}
|
||||
>
|
||||
<div className="main-content-area" style={{ minHeight: '100%', boxSizing: 'border-box' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
<style>{`
|
||||
.main-content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.main-content-area {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
// Used by auth + maintenance pages — full-bleed, no shell.
|
||||
export default function MinimalLayout() {
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||
<div style={{ minHeight: '100vh', backgroundColor: '#f8fafc' }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
30
src/main.jsx
30
src/main.jsx
@@ -1,22 +1,28 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
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';
|
||||
import { BrowserRouter, Link } from 'react-router-dom';
|
||||
|
||||
import theme from '@/theme';
|
||||
// Astryx UI CSS
|
||||
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';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<BrowserRouter>
|
||||
<Theme theme={neutralTheme}>
|
||||
<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 />
|
||||
</BrowserRouter>
|
||||
</LocalizationProvider>
|
||||
</ThemeProvider>
|
||||
</LinkProvider>
|
||||
</BrowserRouter>
|
||||
</Theme>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
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 { LayoutDashboard, Building2, Users, Settings, DollarSign, Calendar } from 'lucide-react';
|
||||
|
||||
// ==============================|| DOORMILE - SIDEBAR NAV CONFIG ||============================== //
|
||||
|
||||
@@ -9,15 +6,18 @@ const navItems = [
|
||||
{
|
||||
group: 'CRM',
|
||||
items: [
|
||||
{ id: 'dashboard', title: 'Dashboard', url: '/dashboard', icon: DashboardOutlinedIcon },
|
||||
{ id: 'tenants', title: 'Clients', url: '/tenants', icon: ApartmentOutlinedIcon },
|
||||
{ id: 'team-users', title: 'App Users', url: '/team-users', icon: GroupsOutlinedIcon }
|
||||
{ id: 'dashboard', title: 'Dashboard', url: '/dashboard', icon: LayoutDashboard },
|
||||
{ id: 'tenants', title: 'Clients', url: '/clients', icon: Building2 },
|
||||
{ id: 'survey', title: 'Providers', url: '/survey', icon: Users },
|
||||
{ id: 'pricing', title: 'Pricing Matrix', url: '/pricing', icon: DollarSign },
|
||||
{ id: 'bookings', title: 'Bookings', url: '/bookings', icon: Calendar },
|
||||
{ id: 'team-users', title: 'App Users', url: '/team-users', icon: Users }
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'System',
|
||||
items: [
|
||||
{ id: 'settings', title: 'Settings', url: '/settings', icon: SettingsOutlinedIcon }
|
||||
{ id: 'settings', title: 'Settings', url: '/settings', icon: Settings }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
Grid, Card, Box, Stack, Typography, Button, Divider, LinearProgress, CircularProgress, Alert,
|
||||
Table, TableBody, TableCell, TableHead, TableRow, TableContainer, useMediaQuery
|
||||
} from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
|
||||
import FiberNewOutlinedIcon from '@mui/icons-material/FiberNewOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import HandshakeOutlinedIcon from '@mui/icons-material/HandshakeOutlined';
|
||||
import HistoryOutlinedIcon from '@mui/icons-material/HistoryOutlined';
|
||||
import DonutLargeOutlinedIcon from '@mui/icons-material/DonutLargeOutlined';
|
||||
import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined';
|
||||
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
||||
Building2,
|
||||
UserPlus,
|
||||
Package,
|
||||
Handshake,
|
||||
History,
|
||||
PieChart,
|
||||
Layers,
|
||||
Users,
|
||||
ArrowRight,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
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 { Divider } from '@astryxdesign/core/Divider';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatCard from '@/components/StatCard';
|
||||
@@ -20,88 +27,96 @@ import StatusChip from '@/components/StatusChip';
|
||||
import DonutChart from '@/components/charts/DonutChart';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { fetchPoints, COLLECTIONS } from '@/utils/qdrant';
|
||||
import bgImage from '@/assets/premium_logistics_bg.png';
|
||||
|
||||
const titleCase = (s) =>
|
||||
String(s || '').replace(/[_-]+/g, ' ').replace(/([a-z\d])([A-Z])/g, '$1 $2').replace(/\b\w/g, (c) => c.toUpperCase()).trim();
|
||||
import { fetchClients, fetchUsers } from '@/utils/apiClient';
|
||||
import { toClient, toUser } from '@/utils/mappers';
|
||||
import { titleCase } from '@/utils/format';
|
||||
|
||||
const STATUS_COLOR = { newclient: '#00A2AE', contacted: '#FFBF00', onboarded: '#00A854', lost: '#F04134' };
|
||||
const statusColor = (s) => STATUS_COLOR[String(s || '').toLowerCase()] || '#8C8C8C';
|
||||
const BAR_COLORS = ['#C01227', '#00A2AE', '#00A854', '#FFBF00', '#9E0E20', '#8C8C8C', '#D6515C'];
|
||||
|
||||
const generateLogicalId = (id) => {
|
||||
const str = String(id).replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||
return 'CLI-' + str.substring(0, 6).padStart(6, '0');
|
||||
};
|
||||
|
||||
function toClient(point) {
|
||||
const p = point.payload || {};
|
||||
return {
|
||||
id: point.id,
|
||||
logicalId: generateLogicalId(point.id),
|
||||
name: p.name || '—',
|
||||
businessType: p.businessType || '',
|
||||
city: p.city || '',
|
||||
businessState: p.businessState || '',
|
||||
status: p.status || 'unknown',
|
||||
parcelVolume: Number(p.parcelVolume) || 0,
|
||||
activeContracts: Number(p.activeContracts) || 0,
|
||||
lastUpdated: p.lastUpdated || ''
|
||||
};
|
||||
}
|
||||
function toUser(point) {
|
||||
const p = point.payload || {};
|
||||
return { id: point.id, name: p.name || '—', email: p.email || '', role: p.role || 'unknown' };
|
||||
}
|
||||
|
||||
// Card with a tinted icon header band.
|
||||
function Panel({ icon: Icon, title, action, color = 'primary', noPadding = false, children }) {
|
||||
return (
|
||||
<Card sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.03)',
|
||||
border: '1px solid rgba(0,0,0,0.04)'
|
||||
}}>
|
||||
<Stack
|
||||
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%)`
|
||||
<Card
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
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)',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid rgba(5, 54, 89, 0.06)',
|
||||
background: 'linear-gradient(90deg, rgba(10, 19, 23, 0.03) 0%, rgba(255, 255, 255, 0) 100%)',
|
||||
gap: '10px',
|
||||
flexWrap: 'wrap'
|
||||
}}
|
||||
>
|
||||
<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)' }}>
|
||||
<Icon fontSize="small" />
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'grey.800', flexGrow: 1, letterSpacing: '-0.3px' }}>{title}</Typography>
|
||||
<div
|
||||
style={{
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
borderRadius: '7px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(10, 19, 23, 0.08)',
|
||||
color: '#0A1317',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<Icon size={15} />
|
||||
</div>
|
||||
<div style={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Heading level={5}>{title}</Heading>
|
||||
</div>
|
||||
{action}
|
||||
</Stack>
|
||||
<Box sx={{ p: noPadding ? 0 : { xs: 2, sm: 3 }, flexGrow: 1 }}>{children}</Box>
|
||||
</div>
|
||||
<div style={{ padding: noPadding ? '0' : '16px', flexGrow: 1, boxSizing: 'border-box' }}>
|
||||
{children}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const navigate = useNavigate();
|
||||
const [selectedDate, setSelectedDate] = useState(() => dayjs().format('YYYY-MM-DD'));
|
||||
const [clients, setClients] = useState([]);
|
||||
const [team, setTeam] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 requestId = ++loadRequestId.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([fetchPoints(COLLECTIONS.clients), fetchPoints(COLLECTIONS.teamUsers)])
|
||||
Promise.all([fetchClients(), fetchUsers()])
|
||||
.then(([cs, us]) => {
|
||||
setClients(cs.map(toClient));
|
||||
setTeam(us.map(toUser));
|
||||
if (loadRequestId.current !== requestId) return;
|
||||
setClients((cs || []).map(toClient));
|
||||
setTeam((us || []).map(toUser));
|
||||
})
|
||||
.catch((e) => setError(e.message || 'Failed to load dashboard data'))
|
||||
.finally(() => setLoading(false));
|
||||
.catch((e) => {
|
||||
if (loadRequestId.current === requestId) setError(e.message || 'Failed to load dashboard data');
|
||||
})
|
||||
.finally(() => {
|
||||
if (loadRequestId.current === requestId) setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
@@ -131,148 +146,287 @@ export default function Dashboard() {
|
||||
[clients]
|
||||
);
|
||||
|
||||
const today = new Date().toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
const recentColumns = useMemo(() => [
|
||||
{
|
||||
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 = '#0A1317';
|
||||
e.target.style.boxShadow = '0 0 0 3px rgba(10, 19, 23, 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) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Dashboard" breadcrumbs={[{ label: 'Dashboard' }]} />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 12 }}><CircularProgress /></Box>
|
||||
</>
|
||||
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
||||
<PageHeader title="Dashboard" action={datePickerAction} />
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '96px 0' }}>
|
||||
<div className="spinner" style={{ width: '40px', height: '40px', border: '4px solid #f1f5f9', borderTop: '4px solid #0A1317', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||
<style>{`@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
breadcrumbs={[{ label: 'Dashboard' }]}
|
||||
action={<Button variant="outlined" startIcon={<RefreshIcon />} onClick={load}>Refresh</Button>}
|
||||
/>
|
||||
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
||||
<PageHeader title="Dashboard" action={datePickerAction} />
|
||||
|
||||
{error && <Alert severity="error" sx={{ mb: 2.5 }} action={<Button color="inherit" size="small" onClick={load}>Retry</Button>}>{error}</Alert>}
|
||||
{error && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<Banner
|
||||
status="error"
|
||||
title={error}
|
||||
endContent={<Button label="Reload" variant="ghost" size="sm" onClick={load} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview section */}
|
||||
<Heading level={3}>Overview</Heading>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '12px', marginTop: '12px', marginBottom: '20px' }}>
|
||||
<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="primary" />
|
||||
<StatCard size="sm" title="Total Parcel Volume" value={stats.parcels.toLocaleString('en-IN')} icon={Package} color="primary" />
|
||||
<StatCard size="sm" title="Active Contracts" value={stats.contracts} icon={Handshake} color="primary" />
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Clients" value={stats.total} icon={ApartmentOutlinedIcon} color="primary" caption="All registered" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="New Clients" value={stats.newCount} icon={FiberNewOutlinedIcon} color="primary" caption="Awaiting onboarding" /></Grid>
|
||||
<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>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Active Contracts" value={stats.contracts} icon={HandshakeOutlinedIcon} color="primary" caption="Currently running" /></Grid>
|
||||
{/* Clients section */}
|
||||
<Heading level={3}>Clients</Heading>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '16px', marginTop: '12px', marginBottom: '16px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', lg: 'repeat(12, 1fr)', gap: '16px' }} className="responsive-row">
|
||||
{/* Recent Clients */}
|
||||
<div style={{ gridColumn: 'span 8' }} className="col-span-8">
|
||||
<Panel
|
||||
icon={History}
|
||||
title="Recent Clients"
|
||||
color="primary"
|
||||
noPadding
|
||||
action={recent.length > 0 && (
|
||||
<Button
|
||||
label="View more"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<ArrowRight size={14} />}
|
||||
onClick={() => navigate('/clients')}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{recent.length === 0 ? (
|
||||
<EmptyState icon={Building2} title="No clients yet" caption="Add a client under the Clients menu to see it here." />
|
||||
) : (
|
||||
<div style={{ paddingTop: '8px', paddingBottom: '8px' }}>
|
||||
<Table
|
||||
data={recent}
|
||||
columns={recentColumns}
|
||||
idKey="id"
|
||||
density="compact"
|
||||
dividers="rows"
|
||||
hasHover
|
||||
isStriped
|
||||
textOverflow="truncate"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Grid item xs={12} lg={8}>
|
||||
<Panel icon={HistoryOutlinedIcon} title="Recent Clients" color="primary" noPadding>
|
||||
{recent.length === 0 ? (
|
||||
<EmptyState title="No clients yet" caption="Add a client to see it here." />
|
||||
) : isMobile ? (
|
||||
<Stack divider={<Divider />}>
|
||||
{recent.map((c) => (
|
||||
<Stack key={c.id} direction="row" spacing={1.25} alignItems="center" sx={{ px: 2, py: 1.5 }}>
|
||||
<UserAvatar name={c.name} size={36} />
|
||||
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }} noWrap>{c.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>
|
||||
{[titleCase(c.businessType), c.city].filter(Boolean).join(' · ') || '—'} · {c.parcelVolume.toLocaleString('en-IN')} parcels
|
||||
</Typography>
|
||||
</Box>
|
||||
<StatusChip status={c.status} />
|
||||
</Stack>
|
||||
))}
|
||||
</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>
|
||||
{/* 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>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Team section */}
|
||||
<Heading level={3}>Team</Heading>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '16px', marginTop: '12px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', lg: 'repeat(12, 1fr)', gap: '16px' }} className="responsive-row">
|
||||
{/* Business Type bar chart */}
|
||||
<div style={{ gridColumn: 'span 6' }} className="col-span-6">
|
||||
<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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Panel>
|
||||
</Grid>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Grid item xs={12} lg={4}>
|
||||
<Panel icon={DonutLargeOutlinedIcon} title="Clients by Status" color="primary">
|
||||
<Box sx={{ py: 1.5 }}>
|
||||
{statusData.length === 0
|
||||
? <EmptyState title="No data" />
|
||||
: <DonutChart data={statusData} centerValue={stats.total} centerLabel="Clients" />}
|
||||
</Box>
|
||||
</Panel>
|
||||
</Grid>
|
||||
{/* App Users */}
|
||||
<div style={{ gridColumn: 'span 6' }} className="col-span-6">
|
||||
<Panel
|
||||
icon={Users}
|
||||
title="App Users"
|
||||
color="primary"
|
||||
>
|
||||
{team.length === 0 ? (
|
||||
<EmptyState title="No team users" />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', maxHeight: '324px', overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
{team.map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
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} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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;
|
||||
}
|
||||
.col-span-6 {
|
||||
grid-column: span 6 !important;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.responsive-row {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
.col-span-8, .col-span-4, .col-span-6 {
|
||||
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(10, 19, 23, 0.2) !important;
|
||||
}
|
||||
|
||||
<Grid item xs={12} lg={4}>
|
||||
<Panel icon={GroupsOutlinedIcon} title={`App Users · ${team.length}`} color="primary">
|
||||
{team.length === 0 ? (
|
||||
<EmptyState title="No team users" />
|
||||
) : (
|
||||
<Stack divider={<Divider />} spacing={0}>
|
||||
{team.slice(0, 6).map((u) => (
|
||||
<Stack key={u.id} direction="row" spacing={1.5} alignItems="center" sx={{ py: 1.25 }}>
|
||||
<UserAvatar name={u.name} size={36} />
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>{u.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>{u.email}</Typography>
|
||||
</Box>
|
||||
<StatusChip status={u.role} />
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Panel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
/* Team member row hover polish */
|
||||
.team-row-hover:hover {
|
||||
background-color: rgba(10, 19, 23, 0.04) !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import {
|
||||
Grid, Card, Box, Stack, TextField, MenuItem, Switch, Button, Typography, Divider,
|
||||
Snackbar, Alert, Chip, IconButton, InputAdornment, LinearProgress, Avatar
|
||||
} from '@mui/material';
|
||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||
import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined';
|
||||
import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone';
|
||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||
import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined';
|
||||
import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined';
|
||||
import BusinessOutlinedIcon from '@mui/icons-material/BusinessOutlined';
|
||||
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined';
|
||||
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
|
||||
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
|
||||
import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import Visibility from '@mui/icons-material/Visibility';
|
||||
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||
Save,
|
||||
Sliders,
|
||||
Bell,
|
||||
Lock,
|
||||
Shield,
|
||||
Megaphone,
|
||||
Building,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
HelpCircle,
|
||||
LogOut,
|
||||
Eye,
|
||||
EyeOff
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { List, ListItem } from '@astryxdesign/core/List';
|
||||
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 { Divider } from '@astryxdesign/core/Divider';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
|
||||
@@ -24,7 +30,7 @@ const TIMEZONES = ['Asia/Kolkata (IST)', 'Asia/Dubai (GST)', 'UTC', 'America/New
|
||||
const LANGUAGES = ['English', 'हिन्दी (Hindi)', 'العربية (Arabic)'];
|
||||
|
||||
const INITIAL_GENERAL = {
|
||||
orgName: 'Doormile Logistics Pvt. Ltd.',
|
||||
orgName: 'Doormile Technologies',
|
||||
supportEmail: 'support@doormile.in',
|
||||
contact: '+91 63749 46729',
|
||||
timezone: TIMEZONES[0],
|
||||
@@ -35,10 +41,28 @@ const INITIAL_NOTIFY = {
|
||||
};
|
||||
const INITIAL_SECURITY = { currentPassword: '', newPassword: '', confirmPassword: '', twoFactor: false };
|
||||
|
||||
// No backend endpoint exists yet for organisation settings (confirmed: apiClient.js has
|
||||
// none, and there's no swagger/docs to design one against — see git history for this
|
||||
// page). Persisting to localStorage keeps Save/Discard honest and durable across reloads
|
||||
// without pretending to sync to a server. Password fields are deliberately excluded —
|
||||
// never write plaintext credentials to localStorage.
|
||||
const SETTINGS_STORAGE_KEY = 'doormile_settings_v1';
|
||||
// data-URL logos bloat localStorage fast (5-10MB browser quota); cap the source file.
|
||||
const MAX_LOGO_BYTES = 1.5 * 1024 * 1024;
|
||||
|
||||
function loadSavedSettings() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
{ icon: TuneOutlinedIcon, label: 'General', desc: 'Organisation profile' },
|
||||
{ icon: NotificationsNoneIcon, label: 'Notifications', desc: 'Alerts & channels' },
|
||||
{ icon: LockOutlinedIcon, label: 'Security', desc: 'Password & 2FA' }
|
||||
{ icon: Sliders, label: 'General', desc: 'Organisation profile' },
|
||||
{ icon: Bell, label: 'Notifications', desc: 'Alerts & channels' },
|
||||
{ icon: Lock, label: 'Security', desc: 'Password & 2FA' }
|
||||
];
|
||||
|
||||
const NOTIFY_ROWS = [
|
||||
@@ -49,12 +73,13 @@ const NOTIFY_ROWS = [
|
||||
];
|
||||
|
||||
const STRENGTH = [
|
||||
{ label: 'Too weak', color: 'error' },
|
||||
{ label: 'Weak', color: 'error' },
|
||||
{ label: 'Fair', color: 'warning' },
|
||||
{ label: 'Good', color: 'info' },
|
||||
{ label: 'Strong', color: 'success' }
|
||||
{ label: 'Too weak', color: '#ef4444' },
|
||||
{ label: 'Weak', color: '#ef4444' },
|
||||
{ label: 'Fair', color: '#f59e0b' },
|
||||
{ label: 'Good', color: '#3b82f6' },
|
||||
{ label: 'Strong', color: '#10b981' }
|
||||
];
|
||||
|
||||
const scorePassword = (pw) => {
|
||||
let s = 0;
|
||||
if (pw.length >= 8) s++;
|
||||
@@ -64,63 +89,92 @@ const scorePassword = (pw) => {
|
||||
return s;
|
||||
};
|
||||
|
||||
// Section surface with a tinted icon header band.
|
||||
function Section({ icon: Icon, title, subtitle, color = 'primary', danger = false, children }) {
|
||||
function Section({ icon: Icon, title, subtitle, danger = false, children }) {
|
||||
return (
|
||||
<Card sx={danger ? { borderColor: 'error.light' } : undefined}>
|
||||
<Stack
|
||||
direction="row" spacing={1.75} alignItems="center"
|
||||
sx={{
|
||||
px: { xs: 2, sm: 3 }, py: 2.25, borderBottom: 1, borderColor: 'divider',
|
||||
background: (theme) => `linear-gradient(90deg, ${theme.palette[color].lighter}66 0%, ${theme.palette.background.paper} 72%)`
|
||||
<Card style={{ padding: '0', borderRadius: '16px', border: danger ? '1px solid #fca5a5' : '1px solid rgba(5, 54, 89, 0.08)', backgroundColor: '#ffffff', overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
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(10, 19, 23, 0.04) 0%, #ffffff 72%)',
|
||||
gap: '12px'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 40, height: 40, borderRadius: 2, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: `${color}.lighter`, color: `${color}.main` }}>
|
||||
<Icon fontSize="small" />
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.3 }}>{title}</Typography>
|
||||
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>}
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box sx={{ px: { xs: 2, sm: 3 } }}>{children}</Box>
|
||||
<div style={{ width: '40px', height: '40px', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: danger ? 'rgba(239, 68, 68, 0.08)' : 'rgba(10, 19, 23, 0.08)', color: danger ? '#ef4444' : '#0A1317' }}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<Heading level={3}>{title}</Heading>
|
||||
{subtitle && <Text type="supporting" color="secondary">{subtitle}</Text>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '4px 24px' }}>{children}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Two-column row: label + helper on the left, control on the right.
|
||||
function Row({ label, description, children, align = 'center' }) {
|
||||
function Row({ label, description, controlAlign = 'stretch', children }) {
|
||||
return (
|
||||
<Grid
|
||||
container spacing={2} alignItems={align}
|
||||
sx={{ py: 2.5, borderRadius: 2, transition: 'background-color .15s', '&:hover': { bgcolor: 'grey.50' } }}
|
||||
>
|
||||
<Grid item xs={12} sm={5}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'grey.800' }}>{label}</Typography>
|
||||
{description && <Typography variant="caption" color="text.secondary">{description}</Typography>}
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={7}>{children}</Grid>
|
||||
</Grid>
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', padding: '16px 0', flexWrap: 'wrap', gap: '12px' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', width: '240px', flexShrink: 0 }}>
|
||||
<Text type="body" weight="semibold">{label}</Text>
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
<Divider className="settings-row-divider" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const rightAlign = { display: 'flex', justifyContent: { sm: 'flex-end' } };
|
||||
|
||||
function PasswordField({ label, value, onChange, autoComplete }) {
|
||||
function PasswordField({ label, value, onChange }) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<TextField
|
||||
fullWidth size="small" type={show ? 'text' : 'password'} label={label} value={value} onChange={onChange} autoComplete={autoComplete}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShow((s) => !s)} edge="end" size="small">
|
||||
{show ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
<TextInput
|
||||
label={label}
|
||||
isLabelHidden
|
||||
type={show ? 'text' : 'password'}
|
||||
placeholder={label}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,19 +183,58 @@ export default function Settings() {
|
||||
const [toast, setToast] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const [general, setGeneral] = useState(INITIAL_GENERAL);
|
||||
const [notify, setNotify] = useState(INITIAL_NOTIFY);
|
||||
const [security, setSecurity] = useState(INITIAL_SECURITY);
|
||||
// savedRef tracks the last-persisted snapshot so Discard reverts to "what's actually
|
||||
// saved" rather than jumping back to hardcoded factory defaults.
|
||||
const savedRef = useRef(loadSavedSettings());
|
||||
const saved = savedRef.current;
|
||||
|
||||
const setG = (k) => (e) => { setGeneral((p) => ({ ...p, [k]: e.target.value })); setDirty(true); };
|
||||
const setN = (k) => (e) => { setNotify((p) => ({ ...p, [k]: e.target.checked })); setDirty(true); };
|
||||
const setSText = (k) => (e) => { setSecurity((p) => ({ ...p, [k]: e.target.value })); setDirty(true); };
|
||||
const [general, setGeneral] = useState(saved?.general || INITIAL_GENERAL);
|
||||
const [notify, setNotify] = useState(saved?.notify || INITIAL_NOTIFY);
|
||||
const [security, setSecurity] = useState({ ...INITIAL_SECURITY, twoFactor: saved?.twoFactor ?? false });
|
||||
const [logoUrl, setLogoUrl] = useState(saved?.logoUrl ?? null);
|
||||
const [logoError, setLogoError] = useState('');
|
||||
const logoInputRef = useRef(null);
|
||||
|
||||
const save = () => { setToast(true); setDirty(false); };
|
||||
const setG = (k) => (val) => { setGeneral((p) => ({ ...p, [k]: val })); setDirty(true); };
|
||||
const setN = (k) => (val) => { setNotify((p) => ({ ...p, [k]: val })); setDirty(true); };
|
||||
const setSText = (k) => (val) => { setSecurity((p) => ({ ...p, [k]: val })); setDirty(true); };
|
||||
|
||||
const handleLogoChange = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
if (file.size > MAX_LOGO_BYTES) {
|
||||
setLogoError(`Logo is too large (${(file.size / 1024 / 1024).toFixed(1)}MB) — please use an image under ${MAX_LOGO_BYTES / 1024 / 1024}MB.`);
|
||||
return;
|
||||
}
|
||||
setLogoError('');
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => { setLogoUrl(reader.result); setDirty(true); };
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const snapshot = { general, notify, twoFactor: security.twoFactor, logoUrl };
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
// Most likely QuotaExceededError from a large logo data-URL — surface it instead
|
||||
// of showing a false "saved successfully" toast.
|
||||
setLogoError('Could not save — local storage is full. Try a smaller logo image.');
|
||||
return;
|
||||
}
|
||||
savedRef.current = snapshot;
|
||||
setToast(true);
|
||||
setDirty(false);
|
||||
setTimeout(() => setToast(false), 2500);
|
||||
};
|
||||
const discard = () => {
|
||||
setGeneral(INITIAL_GENERAL);
|
||||
setNotify(INITIAL_NOTIFY);
|
||||
setSecurity(INITIAL_SECURITY);
|
||||
const last = savedRef.current;
|
||||
setGeneral(last?.general || INITIAL_GENERAL);
|
||||
setNotify(last?.notify || INITIAL_NOTIFY);
|
||||
setSecurity({ ...INITIAL_SECURITY, twoFactor: last?.twoFactor ?? false });
|
||||
setLogoUrl(last?.logoUrl ?? null);
|
||||
setLogoError('');
|
||||
setDirty(false);
|
||||
};
|
||||
|
||||
@@ -150,211 +243,227 @@ export default function Settings() {
|
||||
const mismatch = security.confirmPassword && security.newPassword !== security.confirmPassword;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
breadcrumbs={[{ label: 'Settings' }]}
|
||||
action={
|
||||
<Stack
|
||||
direction="row" spacing={1.5} alignItems="center" useFlexGap flexWrap="wrap"
|
||||
sx={{ width: { xs: '100%', sm: 'auto' }, justifyContent: { xs: 'flex-start', sm: 'flex-end' } }}
|
||||
>
|
||||
{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: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
{dirty && <Badge variant="warning" label="Unsaved changes" />}
|
||||
<Button variant="ghost" onClick={discard} label="Discard" />
|
||||
<Button variant="primary" icon={<Save size={14} />} onClick={save} label="Save Changes" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2.5}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', lg: 'repeat(12, 1fr)', gap: '24px', marginTop: '24px' }} className="responsive-row">
|
||||
{/* Sidebar */}
|
||||
<Grid item xs={12} md={3}>
|
||||
<Stack spacing={2.5} sx={{ position: { md: 'sticky' }, top: { md: 88 } }}>
|
||||
<Card sx={{ p: 1.5 }}>
|
||||
<Typography variant="overline" sx={{ px: 1, color: 'text.secondary', fontWeight: 700, letterSpacing: 0.6 }}>Preferences</Typography>
|
||||
<Stack spacing={0.25} sx={{ mt: 0.5 }}>
|
||||
{NAV.map((item, i) => {
|
||||
const active = tab === i;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Stack
|
||||
key={item.label}
|
||||
direction="row" spacing={1.5} alignItems="center"
|
||||
onClick={() => setTab(i)}
|
||||
sx={{
|
||||
px: 1.5, py: 1.25, borderRadius: 2, cursor: 'pointer', position: 'relative',
|
||||
bgcolor: active ? 'primary.lighter' : 'transparent',
|
||||
transition: 'background-color .15s',
|
||||
'&:hover': { bgcolor: active ? 'primary.lighter' : 'grey.50' },
|
||||
'&::before': active ? { content: '""', position: 'absolute', left: 0, top: 9, bottom: 9, width: 3, borderRadius: 3, bgcolor: 'primary.main' } : {}
|
||||
}}
|
||||
>
|
||||
<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' }}>
|
||||
<Icon fontSize="small" />
|
||||
</Box>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: active ? 'primary.main' : 'grey.800' }}>{item.label}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{item.desc}</Typography>
|
||||
</Box>
|
||||
<ChevronRightIcon sx={{ fontSize: 18, color: active ? 'primary.main' : 'grey.300' }} />
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
<div style={{ gridColumn: 'span 3' }} className="col-span-3">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<Card style={{ padding: '8px' }}>
|
||||
<List density="spacious">
|
||||
{NAV.map((item, i) => (
|
||||
<ListItem
|
||||
key={item.label}
|
||||
label={item.label}
|
||||
description={item.desc}
|
||||
startContent={<item.icon size={16} />}
|
||||
isSelected={tab === i}
|
||||
onClick={() => setTab(i)}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
|
||||
<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 style={{ padding: '20px', backgroundColor: 'rgba(10, 19, 23, 0.05)', border: '1px solid rgba(10, 19, 23, 0.1)' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ width: '38px', height: '38px', borderRadius: '8px', backgroundColor: 'rgba(10, 19, 23, 0.08)', color: '#0A1317', 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>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Grid item xs={12} md={9}>
|
||||
<div style={{ gridColumn: 'span 9' }} className="col-span-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%)` }}
|
||||
<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(10, 19, 23, 0.05) 0%, #ffffff 75%)',
|
||||
flexWrap: 'wrap'
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<div
|
||||
style={{ width: '64px', height: '64px', borderRadius: '8px', backgroundColor: logoUrl ? 'transparent' : '#0A1317', color: '#ffffff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, overflow: 'hidden' }}
|
||||
>
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="Organisation logo" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<Building size={32} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Heading level={4}>{general.orgName}</Heading>
|
||||
<Badge variant="success" icon={<CheckCircle2 size={12} />} label="Verified" />
|
||||
</div>
|
||||
<Text type="supporting" color="secondary">{general.supportEmail} · {general.contact}</Text>
|
||||
</div>
|
||||
<input
|
||||
ref={logoInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleLogoChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '4px' }}>
|
||||
<Button variant="secondary" size="sm" label="Change logo" onClick={() => logoInputRef.current?.click()} />
|
||||
{logoError && <Text type="supporting" style={{ color: '#ef4444', textAlign: 'right', maxWidth: '220px' }}>{logoError}</Text>}
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<TextField fullWidth size="small" value={general.orgName} onChange={setG('orgName')} />
|
||||
</Row>
|
||||
<Row label="Support email" description="Where customer replies are routed">
|
||||
<TextField fullWidth size="small" value={general.supportEmail} onChange={setG('supportEmail')} />
|
||||
</Row>
|
||||
<Row label="Contact number" description="Primary operations line">
|
||||
<TextField fullWidth size="small" value={general.contact} onChange={setG('contact')} />
|
||||
</Row>
|
||||
<Row label="Timezone" description="Used for schedules and reports">
|
||||
<TextField select fullWidth size="small" value={general.timezone} onChange={setG('timezone')}>
|
||||
{TIMEZONES.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)}
|
||||
</TextField>
|
||||
</Row>
|
||||
<Row label="Language" description="Console display language">
|
||||
<TextField select fullWidth size="small" value={general.language} onChange={setG('language')}>
|
||||
{LANGUAGES.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)}
|
||||
</TextField>
|
||||
</Row>
|
||||
</Stack>
|
||||
<Section icon={Sliders} title="Organisation" subtitle="Profile and regional preferences">
|
||||
<Row label="Organisation name" description="Shown on invoices and exports">
|
||||
<TextInput label="Organisation name" isLabelHidden width="100%" value={general.orgName} onChange={setG('orgName')} />
|
||||
</Row>
|
||||
<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')} />
|
||||
</Row>
|
||||
<Row label="Contact number" description="Primary operations line">
|
||||
<TextInput label="Contact number" isLabelHidden width="100%" value={general.contact} onChange={setG('contact')} />
|
||||
</Row>
|
||||
<Row label="Timezone" description="Used for schedules and reports">
|
||||
<div style={{ width: '100%' }}>
|
||||
<Selector label="Timezone" isLabelHidden options={TIMEZONES} value={general.timezone} onChange={(v) => setG('timezone')(v || '')} />
|
||||
</div>
|
||||
</Row>
|
||||
<Row label="Language" description="Console display language">
|
||||
<div style={{ width: '100%' }}>
|
||||
<Selector label="Language" isLabelHidden options={LANGUAGES} value={general.language} onChange={(v) => setG('language')(v || '')} />
|
||||
</div>
|
||||
</Row>
|
||||
</Section>
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 1 && (
|
||||
<Stack spacing={2.5}>
|
||||
<Section icon={NotificationsNoneIcon} title="Notification Preferences" subtitle="Choose what you get alerted about" color="primary">
|
||||
<Stack divider={<Divider />}>
|
||||
{NOTIFY_ROWS.map((row) => (
|
||||
<Row key={row.k} label={row.t} description={row.d}>
|
||||
<Box sx={rightAlign}><Switch checked={notify[row.k]} onChange={setN(row.k)} /></Box>
|
||||
</Row>
|
||||
))}
|
||||
</Stack>
|
||||
</Section>
|
||||
<Section icon={CampaignOutlinedIcon} title="Delivery Channels" subtitle="How alerts reach your team" color="primary">
|
||||
<Stack divider={<Divider />}>
|
||||
<Row label="Email alerts" description="Send notifications to the support inbox">
|
||||
<Box sx={rightAlign}><Switch checked={notify.emailAlerts} onChange={setN('emailAlerts')} /></Box>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<Section icon={Bell} title="Notification Preferences" subtitle="Choose what you get alerted about">
|
||||
{NOTIFY_ROWS.map((row) => (
|
||||
<Row key={row.k} label={row.t} description={row.d} controlAlign="end">
|
||||
<Switch label={row.t} isLabelHidden value={notify[row.k]} onChange={setN(row.k)} />
|
||||
</Row>
|
||||
<Row label="SMS alerts" description="Send notifications to the registered mobile">
|
||||
<Box sx={rightAlign}><Switch checked={notify.smsAlerts} onChange={setN('smsAlerts')} /></Box>
|
||||
</Row>
|
||||
</Stack>
|
||||
))}
|
||||
</Section>
|
||||
</Stack>
|
||||
<Section icon={Megaphone} title="Delivery Channels" subtitle="How alerts reach your team">
|
||||
<Row label="Email alerts" description="Send notifications to the support inbox" controlAlign="end">
|
||||
<Switch label="Email alerts" isLabelHidden value={notify.emailAlerts} onChange={setN('emailAlerts')} />
|
||||
</Row>
|
||||
<Row label="SMS alerts" description="Send notifications to the registered mobile" controlAlign="end">
|
||||
<Switch label="SMS alerts" isLabelHidden value={notify.smsAlerts} onChange={setN('smsAlerts')} />
|
||||
</Row>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 2 && (
|
||||
<Stack spacing={2.5}>
|
||||
<Section icon={LockOutlinedIcon} title="Change Password" subtitle="Use 8+ characters with a mix of letters, numbers & symbols" color="primary">
|
||||
<Stack divider={<Divider />}>
|
||||
<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 label="New password" description="Choose a strong, unique password" align="flex-start">
|
||||
<Box>
|
||||
<PasswordField label="New password" value={security.newPassword} onChange={setSText('newPassword')} autoComplete="new-password" />
|
||||
{security.newPassword && (
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mt: 1 }}>
|
||||
<LinearProgress variant="determinate" value={(pwScore / 4) * 100} color={pwMeta.color} sx={{ flexGrow: 1, height: 6, borderRadius: 3 }} />
|
||||
<Typography variant="caption" sx={{ fontWeight: 700, color: `${pwMeta.color}.main`, minWidth: 56 }}>{pwMeta.label}</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Row>
|
||||
<Row label="Confirm new password" description="Re-enter the new password" align="flex-start">
|
||||
<Box>
|
||||
<PasswordField label="Confirm new password" value={security.confirmPassword} onChange={setSText('confirmPassword')} autoComplete="new-password" />
|
||||
{mismatch && <Typography variant="caption" color="error.main" sx={{ mt: 0.75, display: 'block' }}>Passwords do not match</Typography>}
|
||||
</Box>
|
||||
</Row>
|
||||
</Stack>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
<Section icon={Lock} title="Change Password" subtitle="Use 8+ characters with a mix of letters, numbers & symbols">
|
||||
<Row label="Current password" description="Enter your existing password">
|
||||
<PasswordField label="Current password" value={security.currentPassword} onChange={setSText('currentPassword')} />
|
||||
</Row>
|
||||
<Row label="New password" description="Choose a strong, unique password">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', width: '100%' }}>
|
||||
<PasswordField label="New password" value={security.newPassword} onChange={setSText('newPassword')} />
|
||||
{security.newPassword && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginTop: '4px' }}>
|
||||
<div style={{ flexGrow: 1, height: '6px', borderRadius: '3px', backgroundColor: '#f1f5f9', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', backgroundColor: pwMeta.color, width: `${(pwScore / 4) * 100}%` }} />
|
||||
</div>
|
||||
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: pwMeta.color, minWidth: '56px' }}>{pwMeta.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
<Row label="Confirm new password" description="Re-enter the new password">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', width: '100%' }}>
|
||||
<PasswordField label="Confirm new password" value={security.confirmPassword} onChange={setSText('confirmPassword')} />
|
||||
{mismatch && <span style={{ fontSize: '0.75rem', color: '#ef4444', display: 'block', marginTop: '4px' }}>Passwords do not match</span>}
|
||||
</div>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<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">
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ justifyContent: { sm: 'flex-end' } }}>
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<ShieldOutlinedIcon sx={{ fontSize: 15, ml: 0.5 }} />}
|
||||
label={security.twoFactor ? 'Enabled' : 'Disabled'}
|
||||
sx={{ fontWeight: 700, bgcolor: security.twoFactor ? 'success.lighter' : 'grey.100', color: security.twoFactor ? 'success.dark' : 'grey.600', '& .MuiChip-icon': { color: 'inherit' } }}
|
||||
<Section icon={Shield} title="Two-Factor Authentication" subtitle="Add an extra layer of security to your account">
|
||||
<Row label="Authenticator app" description="Require a one-time code at sign-in for extra security" controlAlign="end">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<Badge variant={security.twoFactor ? 'success' : 'neutral'} label={security.twoFactor ? 'Enabled' : 'Disabled'} />
|
||||
<Switch
|
||||
label="Two-factor authentication"
|
||||
isLabelHidden
|
||||
value={security.twoFactor}
|
||||
onChange={(v) => { setSecurity((p) => ({ ...p, twoFactor: v })); setDirty(true); }}
|
||||
/>
|
||||
<Switch checked={security.twoFactor} onChange={(e) => { setSecurity((p) => ({ ...p, twoFactor: e.target.checked })); setDirty(true); }} />
|
||||
</Stack>
|
||||
</div>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<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">
|
||||
<Box sx={rightAlign}>
|
||||
<Button variant="outlined" color="error" startIcon={<LogoutOutlinedIcon />} onClick={() => { localStorage.removeItem('auth_token'); window.location.href = '/login'; }}>Sign out everywhere</Button>
|
||||
</Box>
|
||||
<Section icon={AlertTriangle} title="Danger Zone" subtitle="Irreversible and high-impact actions" danger>
|
||||
<Row label="Sign out of all sessions" description="End every active session on all devices" controlAlign="end">
|
||||
<Button variant="destructive" onClick={() => { localStorage.removeItem('auth_token'); window.location.href = '/login'; }} label="Sign out everywhere" icon={<LogOut size={14} />} />
|
||||
</Row>
|
||||
</Section>
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Snackbar
|
||||
open={toast}
|
||||
autoHideDuration={2500}
|
||||
onClose={() => setToast(false)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity="success" variant="filled" onClose={() => setToast(false)} sx={{ width: '100%' }}>
|
||||
{toast && (
|
||||
<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' }}>
|
||||
<CheckCircle2 size={16} style={{ color: '#10b981' }} />
|
||||
Settings saved successfully.
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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-divider:last-child {
|
||||
display: none;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,154 +1,173 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Card,
|
||||
Stack,
|
||||
Typography,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
Link
|
||||
} from '@mui/material';
|
||||
import Visibility from '@mui/icons-material/Visibility';
|
||||
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||
import { Eye, EyeOff, Mail, Lock } from 'lucide-react';
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { VStack, HStack } from '@astryxdesign/core/Layout';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Link } from '@astryxdesign/core/Link';
|
||||
import { Banner } from '@astryxdesign/core/Banner';
|
||||
|
||||
import Logo from '@/components/Logo';
|
||||
import bgImage from '../../assets/mid-mile-approach.jpg';
|
||||
import { loginAdmin } from '@/utils/apiClient';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [show, setShow] = useState(false);
|
||||
const [auth, setAuth] = useState('');
|
||||
const [pwd, setPwd] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [rememberMe, setRememberMe] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!auth || !pwd || loading) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await loginAdmin(auth, pwd);
|
||||
localStorage.setItem('logged_in', 'true');
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError(err.message || 'Invalid email or password');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') handleLogin();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
backgroundImage: `url(${bgImage})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(180deg, rgba(15,23,42,0.6) 0%, rgba(192, 18, 39, 0.8) 100%)',
|
||||
zIndex: 1
|
||||
}
|
||||
fontFamily: 'system-ui, sans-serif'
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
width: '100%',
|
||||
maxWidth: 440,
|
||||
p: { xs: 4, sm: 5 },
|
||||
m: 2,
|
||||
background: 'rgba(255, 255, 255, 0.85)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.5)',
|
||||
boxShadow: '0 24px 48px rgba(0,0,0,0.2)',
|
||||
borderRadius: 4,
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(180deg, rgba(15,23,42,0.6) 0%, rgba(10, 19, 23, 0.8) 100%)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'center' }}><Logo /></Box>
|
||||
<Typography variant="h3" sx={{ fontWeight: 800, textAlign: 'center', letterSpacing: '-0.5px', color: '#1e293b' }}>Welcome back</Typography>
|
||||
<Typography variant="body1" sx={{ mt: 1, mb: 4, textAlign: 'center', color: '#475569' }}>
|
||||
Sign in to your Doormile operations account.
|
||||
</Typography>
|
||||
/>
|
||||
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.75, color: '#334155', fontWeight: 600 }}>Auth Name</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Enter your auth name"
|
||||
value={auth}
|
||||
onChange={(e) => setAuth(e.target.value)}
|
||||
sx={{
|
||||
'& .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>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 0.75, color: '#334155', fontWeight: 600 }}>Password</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
type={show ? 'text' : 'password'}
|
||||
placeholder="Enter your password"
|
||||
value={pwd}
|
||||
onChange={(e) => setPwd(e.target.value)}
|
||||
sx={{
|
||||
'& .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)' }
|
||||
}
|
||||
}}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShow((s) => !s)} edge="end" size="small">
|
||||
{show ? <VisibilityOff fontSize="small" sx={{ color: '#94a3b8' }} /> : <Visibility fontSize="small" sx={{ color: '#94a3b8' }} />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<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>
|
||||
<Button
|
||||
fullWidth
|
||||
size="large"
|
||||
variant="contained"
|
||||
onClick={() => { localStorage.setItem('auth_token', 'demo-session'); navigate('/dashboard'); }}
|
||||
sx={{
|
||||
mt: 2,
|
||||
py: 1.5,
|
||||
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)',
|
||||
transform: 'translateY(-1px)'
|
||||
},
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Center axis="both" style={{ position: 'relative', minHeight: '100vh', padding: '16px', boxSizing: 'border-box' }}>
|
||||
<Card
|
||||
padding={8}
|
||||
width="100%"
|
||||
maxWidth={440}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.5)',
|
||||
boxShadow: '0 24px 48px rgba(0,0,0,0.2)',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
<VStack gap={4} hAlign="stretch">
|
||||
<VStack gap={1} hAlign="center">
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<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.
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<Box sx={{ position: 'absolute', bottom: 20, zIndex: 2 }}>
|
||||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.8rem' }}>
|
||||
© {new Date().getFullYear()} Doormile Logistics Pvt. Ltd. All rights reserved.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{error && <Banner status="error" title={error} />}
|
||||
|
||||
<VStack gap={3}>
|
||||
<TextInput
|
||||
label="Email"
|
||||
isRequired
|
||||
size="lg"
|
||||
type="email"
|
||||
placeholder="admin@doormile.com"
|
||||
value={auth}
|
||||
onChange={setAuth}
|
||||
onKeyDown={handleKeyDown}
|
||||
startIcon={<Mail size={16} />}
|
||||
hasAutoFocus
|
||||
/>
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<TextInput
|
||||
label="Password"
|
||||
isRequired
|
||||
size="lg"
|
||||
type={show ? 'text' : 'password'}
|
||||
placeholder="Enter your password"
|
||||
value={pwd}
|
||||
onChange={setPwd}
|
||||
onKeyDown={handleKeyDown}
|
||||
startIcon={<Lock size={16} />}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow((s) => !s)}
|
||||
aria-label={show ? 'Hide password' : 'Show password'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '10px',
|
||||
bottom: 0,
|
||||
height: '36px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
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>
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
label="Sign In"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={handleLogin}
|
||||
isLoading={loading}
|
||||
isDisabled={!auth || !pwd}
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
</Center>
|
||||
|
||||
<div style={{ position: 'absolute', bottom: '20px', left: 0, right: 0, textAlign: 'center', zIndex: 2 }}>
|
||||
<Text type="supporting" style={{ color: 'rgba(255, 255, 255, 0.8)' }}>
|
||||
© {new Date().getFullYear()} Doormile Technologies. All rights reserved.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
658
src/pages/bookings/Bookings.jsx
Normal file
658
src/pages/bookings/Bookings.jsx
Normal file
@@ -0,0 +1,658 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Table, proportional, pixel } from '@astryxdesign/core/Table';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||
import { FormLayout } from '@astryxdesign/core/FormLayout';
|
||||
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 EmptyState from '@/components/EmptyState';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import TabLabelCount from '@/components/TabLabelCount';
|
||||
import TablePagination from '@/components/TablePagination';
|
||||
import { fetchClients } from '@/utils/apiClient';
|
||||
import ClientFormDialog from '../tenants/ClientFormDialog';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || 'https://api.doormile.com/api/v1';
|
||||
|
||||
const CITIES = [
|
||||
'Chennai', 'Coimbatore', 'Madurai', 'Tiruchirappalli', 'Salem', 'Tuticorin', 'Tirupur', 'Erode', 'Vellore', 'Tirunelveli', 'Thanjavur', 'Dindigul', 'Hosur', 'Nagercoil', 'Karur', 'Namakkal', 'Kanchipuram', 'Cuddalore', 'Thoothukudi',
|
||||
'Bengaluru', 'Mysuru', 'Mangaluru', 'Hubli', 'Belagavi', 'Kalaburagi', 'Davangere', 'Ballari',
|
||||
'Kochi', 'Thiruvananthapuram', 'Kozhikode', 'Kannur', 'Thrissur', 'Kollam',
|
||||
'Hyderabad', 'Warangal', 'Visakhapatnam', 'Vijayawada', 'Tirupati', 'Guntur', 'Rajahmundry', 'Nellore',
|
||||
'Mumbai', 'Pune', 'Nagpur', 'Nashik', 'Aurangabad', 'Kolhapur', 'Solapur',
|
||||
'New Delhi', 'Gurugram', 'Noida', 'Faridabad', 'Chandigarh', 'Amritsar', 'Ludhiana', 'Jalandhar',
|
||||
'Ahmedabad', 'Surat', 'Vadodara', 'Rajkot', 'Bhavnagar', 'Jamnagar',
|
||||
'Kolkata', 'Howrah', 'Durgapur', 'Asansol', 'Siliguri',
|
||||
'Lucknow', 'Kanpur', 'Agra', 'Varanasi', 'Allahabad', 'Meerut',
|
||||
'Bhopal', 'Indore', 'Gwalior', 'Jabalpur',
|
||||
'Jaipur', 'Jodhpur', 'Udaipur', 'Kota', 'Bikaner',
|
||||
'Patna', 'Gaya', 'Bhagalpur',
|
||||
'Bhubaneswar', 'Cuttack', 'Rourkela',
|
||||
'Guwahati', 'Raipur', 'Ranchi', 'Dehradun'
|
||||
];
|
||||
|
||||
const getHeaders = () => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
async function apiFetchBookings() {
|
||||
const res = await fetch(`${API_BASE}/admin/bookings`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error('Failed to fetch bookings');
|
||||
const json = await res.json();
|
||||
return Array.isArray(json) ? json : (json.data || []);
|
||||
}
|
||||
|
||||
async function apiFetchSurveys() {
|
||||
const res = await fetch(`${API_BASE}/admin/competitor-branches?limit=1000`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error('Failed to fetch surveys');
|
||||
const json = await res.json();
|
||||
return Array.isArray(json) ? json : (json.data || []);
|
||||
}
|
||||
|
||||
async function apiCreateBooking(payload) {
|
||||
const res = await fetch(`${API_BASE}/admin/crmbooking`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Failed to create booking');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function BookingFormDialog({ open, onClose, onSave, clients, surveys }) {
|
||||
const [clientDialogOpen, setClientDialogOpen] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
customer_name: '',
|
||||
customer_phone: '',
|
||||
pickupaddress: '',
|
||||
pickuppincode: '',
|
||||
deliveryaddress: '',
|
||||
deliverypincode: '',
|
||||
deliverycity: '',
|
||||
providercompany: '',
|
||||
providerlocation: '',
|
||||
notes: '',
|
||||
service_option: 'Normal',
|
||||
finalprice: '',
|
||||
insuranceamount: '',
|
||||
needsinsurance: false,
|
||||
declaredvalue: '',
|
||||
parcels: [{ itemcategory: '', weight: '', length: '', width: '', height: '' }]
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [quoteInfo, setQuoteInfo] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData({
|
||||
customer_name: '',
|
||||
customer_phone: '',
|
||||
pickupaddress: '',
|
||||
pickuppincode: '',
|
||||
deliveryaddress: '',
|
||||
deliverypincode: '',
|
||||
deliverycity: '',
|
||||
providercompany: '',
|
||||
providerlocation: '',
|
||||
notes: '',
|
||||
service_option: 'Normal',
|
||||
finalprice: '',
|
||||
insuranceamount: '',
|
||||
needsinsurance: false,
|
||||
declaredvalue: '',
|
||||
parcels: [{ itemcategory: '', weight: '', length: '', width: '', height: '' }]
|
||||
});
|
||||
setError(null);
|
||||
setQuoteInfo(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const set = (field) => (val) => {
|
||||
setFormData(prev => ({ ...prev, [field]: val }));
|
||||
};
|
||||
|
||||
const handleParcelChange = (index, field) => (val) => {
|
||||
const newParcels = [...formData.parcels];
|
||||
newParcels[index] = { ...newParcels[index], [field]: val };
|
||||
setFormData(prev => ({ ...prev, parcels: newParcels }));
|
||||
};
|
||||
|
||||
const addParcel = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
parcels: [...prev.parcels, { itemcategory: '', weight: '', length: '', width: '', height: '' }]
|
||||
}));
|
||||
};
|
||||
|
||||
const removeParcel = (index) => {
|
||||
if (formData.parcels.length <= 1) return;
|
||||
const newParcels = [...formData.parcels];
|
||||
newParcels.splice(index, 1);
|
||||
setFormData(prev => ({ ...prev, parcels: newParcels }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (!formData.pickupaddress || !formData.pickuppincode) {
|
||||
throw new Error('Pickup Address and Pincode are required');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...formData,
|
||||
finalprice: parseFloat(formData.finalprice) || 0,
|
||||
insuranceamount: parseFloat(formData.insuranceamount) || 0,
|
||||
parcels: formData.parcels.map(p => ({
|
||||
itemcategory: p.itemcategory || 'General',
|
||||
weight: parseFloat(p.weight) || 1.0,
|
||||
length: parseFloat(p.length) || 1.0,
|
||||
width: parseFloat(p.width) || 1.0,
|
||||
height: parseFloat(p.height) || 1.0,
|
||||
}))
|
||||
};
|
||||
|
||||
await apiCreateBooking(payload);
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err.message || 'Failed to create booking');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckPrice = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/pricing/quote`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({
|
||||
parcels: formData.parcels.map(p => ({
|
||||
weight: parseFloat(p.weight) || 1.0,
|
||||
length: parseFloat(p.length) || 1.0,
|
||||
width: parseFloat(p.width) || 1.0,
|
||||
height: parseFloat(p.height) || 1.0,
|
||||
}))
|
||||
})
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setQuoteInfo(data);
|
||||
if (!formData.finalprice) {
|
||||
setFormData(prev => ({ ...prev, finalprice: data.basequote?.toFixed(2) || '' }));
|
||||
}
|
||||
}
|
||||
} catch(e) { console.error(e); }
|
||||
};
|
||||
|
||||
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 num = (v) => (v === '' || v == null ? undefined : Number(v));
|
||||
const setNum = (field) => (v) => set(field)(v == null ? '' : v);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog isOpen={open} onOpenChange={(o) => { if (!o) onClose(); }} width={800} maxHeight="90dvh" purpose="form">
|
||||
<Layout
|
||||
header={<DialogHeader title="Create New Booking" onOpenChange={(o) => { if (!o) onClose(); }} />}
|
||||
content={
|
||||
<LayoutContent>
|
||||
<FormLayout>
|
||||
{error && (
|
||||
<div style={{ padding: '12px 16px', backgroundColor: '#fef2f2', border: '1px solid #fca5a5', borderRadius: '8px', color: '#b91c1c', fontSize: '0.875rem' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Customer Details */}
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#0A1317', fontSize: '0.9rem', textTransform: 'uppercase' }}>Customer Details</div>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<div style={{ flexGrow: 1, minWidth: '200px' }}>
|
||||
<Field label="Select Client" inputID="booking-client-input">
|
||||
<input
|
||||
id="booking-client-input"
|
||||
list="booking-clients"
|
||||
onChange={(e) => {
|
||||
const selected = clients.find(c => `${c.first_name || ''} ${c.last_name || ''} - ${c.phone || ''}` === e.target.value);
|
||||
if (selected) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
customer_name: `${selected.first_name || ''} ${selected.last_name || ''}`.trim(),
|
||||
customer_phone: selected.phone || ''
|
||||
}));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, customer_name: '', customer_phone: '' }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id="booking-clients">
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={`${c.first_name || ''} ${c.last_name || ''} - ${c.phone || ''}`} />
|
||||
))}
|
||||
</datalist>
|
||||
</Field>
|
||||
</div>
|
||||
<Button variant="secondary" icon={<Plus size={14} />} onClick={() => setClientDialogOpen(true)} label="New Client" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assigned Provider */}
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#0A1317', fontSize: '0.9rem', textTransform: 'uppercase' }}>Assigned Provider (From Survey)</div>
|
||||
<FormLayout direction="horizontal">
|
||||
<Selector
|
||||
label="Provider Name" placeholder="Select Provider" hasClear
|
||||
options={uniqueProviders}
|
||||
value={formData.providercompany}
|
||||
onChange={(v) => setFormData(prev => ({ ...prev, providercompany: v ?? '', providerlocation: '' }))}
|
||||
/>
|
||||
<Selector
|
||||
label="Provider Location / Sub Hub" placeholder="Select Location" hasClear
|
||||
options={providerLocations}
|
||||
value={formData.providerlocation}
|
||||
onChange={(v) => set('providerlocation')(v ?? '')}
|
||||
isDisabled={!formData.providercompany}
|
||||
/>
|
||||
</FormLayout>
|
||||
</div>
|
||||
|
||||
{/* Pickup Location */}
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#0A1317', fontSize: '0.9rem', textTransform: 'uppercase' }}>Pickup Location</div>
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput label="Pickup Address" isRequired value={formData.pickupaddress} onChange={set('pickupaddress')} />
|
||||
<TextInput label="Pickup Pincode" isRequired value={formData.pickuppincode} onChange={set('pickuppincode')} />
|
||||
</FormLayout>
|
||||
</div>
|
||||
|
||||
{/* Delivery Destination */}
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#0A1317', fontSize: '0.9rem', textTransform: 'uppercase' }}>Delivery Destination</div>
|
||||
<FormLayout>
|
||||
<TextInput label="Delivery Address" value={formData.deliveryaddress} onChange={set('deliveryaddress')} />
|
||||
<FormLayout direction="horizontal">
|
||||
<Field label="City" inputID="delivery-city-input">
|
||||
<input id="delivery-city-input" list="delivery-cities" value={formData.deliverycity} onChange={(e) => set('deliverycity')(e.target.value)} />
|
||||
<datalist id="delivery-cities">
|
||||
{CITIES.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
</Field>
|
||||
<TextInput label="Delivery Pincode" value={formData.deliverypincode} onChange={set('deliverypincode')} />
|
||||
</FormLayout>
|
||||
</FormLayout>
|
||||
</div>
|
||||
|
||||
{/* Parcel Details */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<div style={{ fontWeight: 700, color: '#0A1317', fontSize: '0.9rem', textTransform: 'uppercase' }}>Parcel Details</div>
|
||||
<Button variant="secondary" size="sm" icon={<Plus size={12} />} onClick={addParcel} label="Add Parcel" />
|
||||
</div>
|
||||
|
||||
{formData.parcels.map((parcel, idx) => (
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', marginBottom: '12px', flexWrap: 'wrap' }} key={idx}>
|
||||
<div style={{ flex: '1 1 150px' }}>
|
||||
<TextInput label="Category" isLabelHidden placeholder="Category (e.g. Box)" value={parcel.itemcategory} onChange={handleParcelChange(idx, 'itemcategory')} />
|
||||
</div>
|
||||
<div style={{ width: '90px' }}>
|
||||
<NumberInput label="Weight (kg)" isLabelHidden placeholder="Wt (kg)" hasClear value={num(parcel.weight)} onChange={(v) => handleParcelChange(idx, 'weight')(v == null ? '' : v)} />
|
||||
</div>
|
||||
<div style={{ width: '90px' }}>
|
||||
<NumberInput label="Length (cm)" isLabelHidden placeholder="L (cm)" hasClear value={num(parcel.length)} onChange={(v) => handleParcelChange(idx, 'length')(v == null ? '' : v)} />
|
||||
</div>
|
||||
<div style={{ width: '90px' }}>
|
||||
<NumberInput label="Width (cm)" isLabelHidden placeholder="W (cm)" hasClear value={num(parcel.width)} onChange={(v) => handleParcelChange(idx, 'width')(v == null ? '' : v)} />
|
||||
</div>
|
||||
<div style={{ width: '90px' }}>
|
||||
<NumberInput label="Height (cm)" isLabelHidden placeholder="H (cm)" hasClear value={num(parcel.height)} onChange={(v) => handleParcelChange(idx, 'height')(v == null ? '' : v)} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeParcel(idx)}
|
||||
disabled={formData.parcels.length === 1}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: '#dc2626',
|
||||
display: 'flex',
|
||||
padding: '9px',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
className="action-btn"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Price & Insurance */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', marginBottom: '12px', flexWrap: 'wrap' }}>
|
||||
<div style={{ fontWeight: 700, color: '#0A1317', fontSize: '0.9rem', textTransform: 'uppercase' }}>Price & Insurance</div>
|
||||
<Button variant="secondary" size="sm" onClick={handleCheckPrice} label="Check Price Estimate" />
|
||||
</div>
|
||||
{quoteInfo && (
|
||||
<div style={{ padding: '12px', borderLeft: '4px solid #3b82f6', backgroundColor: '#eff6ff', fontSize: '0.875rem', marginBottom: '16px', color: '#1e3a8a' }}>
|
||||
Estimated Base Price: <strong>₹{quoteInfo.basequote?.toFixed(2)}</strong> (Chargeable Weight: {quoteInfo.chargeableweight} kg)
|
||||
</div>
|
||||
)}
|
||||
<FormLayout direction="horizontal">
|
||||
<NumberInput label="Final Price (₹)" hasClear value={num(formData.finalprice)} onChange={setNum('finalprice')} />
|
||||
<div>
|
||||
<CheckboxInput
|
||||
label="Needs Insurance? (1%)"
|
||||
value={formData.needsinsurance}
|
||||
onChange={(checked) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
needsinsurance: checked,
|
||||
insuranceamount: checked && prev.declaredvalue ? (parseFloat(prev.declaredvalue) * 0.01).toFixed(2) : prev.insuranceamount
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<NumberInput
|
||||
label="Declared Value" hasClear value={num(formData.declaredvalue)}
|
||||
onChange={(v) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
declaredvalue: v == null ? '' : v,
|
||||
insuranceamount: prev.needsinsurance && v != null ? (v * 0.01).toFixed(2) : prev.insuranceamount
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<NumberInput label="Insurance Amount" hasClear value={num(formData.insuranceamount)} onChange={setNum('insuranceamount')} />
|
||||
</FormLayout>
|
||||
</div>
|
||||
|
||||
{/* Additional info */}
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#0A1317', fontSize: '0.9rem', textTransform: 'uppercase' }}>Additional Information</div>
|
||||
<FormLayout direction="horizontal">
|
||||
<Selector
|
||||
label="Select Speed"
|
||||
options={[{ value: 'Normal', label: 'Normal' }, { value: 'Fast', label: 'Fast Express' }]}
|
||||
value={formData.service_option}
|
||||
onChange={set('service_option')}
|
||||
/>
|
||||
<TextInput label="Notes / Remarks" value={formData.notes} onChange={set('notes')} />
|
||||
</FormLayout>
|
||||
</div>
|
||||
</FormLayout>
|
||||
</LayoutContent>
|
||||
}
|
||||
footer={
|
||||
<LayoutFooter hasDivider>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||
<Button label="Cancel" variant="ghost" onClick={onClose} isDisabled={saving} />
|
||||
<Button
|
||||
label={saving ? 'Creating...' : 'Create Booking'}
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
isDisabled={saving || !formData.customer_phone}
|
||||
isLoading={saving}
|
||||
/>
|
||||
</div>
|
||||
</LayoutFooter>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
{clientDialogOpen && (
|
||||
<ClientFormDialog
|
||||
open={clientDialogOpen}
|
||||
mode="create"
|
||||
onClose={() => setClientDialogOpen(false)}
|
||||
onSaved={() => {
|
||||
setClientDialogOpen(false);
|
||||
onSave();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Bookings() {
|
||||
const [bookings, setBookings] = useState([]);
|
||||
const [clients, setClients] = useState([]);
|
||||
const [surveys, setSurveys] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sourceFilter, setSourceFilter] = useState('All');
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rpp, setRpp] = useState(10);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [bookingsData, clientsData, surveysData] = await Promise.all([
|
||||
apiFetchBookings(),
|
||||
fetchClients().catch(() => []),
|
||||
apiFetchSurveys().catch(() => [])
|
||||
]);
|
||||
setBookings(Array.isArray(bookingsData) ? bookingsData : []);
|
||||
setClients(Array.isArray(clientsData) ? clientsData : []);
|
||||
setSurveys(Array.isArray(surveysData) ? surveysData : []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const displayBookings = bookings.filter(b => {
|
||||
const matchesSearch = (b.bookingno || '').toLowerCase().includes(search.toLowerCase()) ||
|
||||
(b.pickupaddress || '').toLowerCase().includes(search.toLowerCase()) ||
|
||||
(b.deliverycity || '').toLowerCase().includes(search.toLowerCase()) ||
|
||||
(b.providercompany || '').toLowerCase().includes(search.toLowerCase());
|
||||
const matchesSource = sourceFilter === 'All' || b.bookingsource === sourceFilter;
|
||||
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 (
|
||||
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
||||
<PageHeader
|
||||
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)' }}>
|
||||
{/* Tab filters */}
|
||||
<div style={{ display: 'flex', padding: '0 24px', borderBottom: '1px solid rgba(5, 54, 89, 0.06)', gap: '24px', overflowX: 'auto' }}>
|
||||
{[
|
||||
{ label: 'All Bookings', value: 'All' },
|
||||
{ label: 'App Bookings', value: 'Customer_App' },
|
||||
{ label: 'CRM Bookings', value: 'CRM_Console' }
|
||||
].map((tab) => (
|
||||
<div
|
||||
key={tab.value}
|
||||
onClick={() => { setSourceFilter(tab.value); setPage(0); }}
|
||||
style={{
|
||||
padding: '16px 0',
|
||||
cursor: 'pointer',
|
||||
borderBottom: sourceFilter === tab.value ? '2px solid #0A1317' : '2px solid transparent',
|
||||
color: sourceFilter === tab.value ? '#0A1317' : '#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} />
|
||||
</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 #0A1317', 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' }}>
|
||||
<Table
|
||||
data={pagedBookings}
|
||||
columns={bookingColumns}
|
||||
idKey="bookingid"
|
||||
density="balanced"
|
||||
dividers="rows"
|
||||
hasHover
|
||||
isStriped
|
||||
textOverflow="truncate"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && displayBookings.length > 0 && (
|
||||
<TablePagination page={page} rpp={rpp} total={displayBookings.length} onPageChange={setPage} onRppChange={setRpp} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<BookingFormDialog
|
||||
open={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
onSave={() => { loadData(); }}
|
||||
clients={clients}
|
||||
surveys={surveys}
|
||||
/>
|
||||
<style>{`
|
||||
.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,5 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Box, Stack, Typography, TextField, Button } from '@mui/material';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Text, Heading } from '@astryxdesign/core/Text';
|
||||
import { Mail } from 'lucide-react';
|
||||
|
||||
import Logo from '@/components/Logo';
|
||||
|
||||
@@ -14,67 +17,73 @@ export default function ComingSoon() {
|
||||
const [email, setEmail] = useState('');
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
textAlign: 'center'
|
||||
padding: '24px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
fontFamily: 'system-ui, sans-serif'
|
||||
}}
|
||||
>
|
||||
<Stack spacing={3} alignItems="center" sx={{ maxWidth: 620, width: '100%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px', alignItems: 'center', maxWidth: '620px', width: '100%' }}>
|
||||
<Logo />
|
||||
<Typography variant="h2" sx={{ fontWeight: 800, color: 'grey.800' }}>Coming Soon</Typography>
|
||||
<Typography variant="body1" color="text.secondary">Something new is on its way</Typography>
|
||||
<Heading level={1} type="display-2">Coming Soon</Heading>
|
||||
<Text type="large" color="secondary">Something new is on its way</Text>
|
||||
|
||||
{/* Countdown */}
|
||||
<Stack direction="row" spacing={{ xs: 1, sm: 2 }} alignItems="center" justifyContent="center">
|
||||
<div style={{ display: 'flex', flexDirection: 'row', gap: '16px', alignItems: 'center', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
{COUNTDOWN.map((c, i) => (
|
||||
<Stack key={c.label} direction="row" spacing={{ xs: 1, sm: 2 }} alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
width: { xs: 64, sm: 84 },
|
||||
py: 2,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'primary.lighter',
|
||||
border: 1,
|
||||
borderColor: 'primary.light'
|
||||
<div key={c.label} style={{ display: 'flex', flexDirection: 'row', gap: '16px', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '84px',
|
||||
padding: '16px 0',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: 'rgba(10, 19, 23, 0.08)',
|
||||
border: '1px solid rgba(10, 19, 23, 0.2)'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h2" sx={{ fontWeight: 800, color: 'primary.main', lineHeight: 1 }}>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 800, color: '#0A1317', lineHeight: 1 }}>
|
||||
{String(c.value).padStart(2, '0')}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.65rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '1px', marginTop: '4px' }}>
|
||||
{c.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
{i < COUNTDOWN.length - 1 && (
|
||||
<Typography variant="h2" sx={{ fontWeight: 800, color: 'primary.main' }}>:</Typography>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 800, color: '#0A1317' }}>:</div>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Subscribe */}
|
||||
<Box sx={{ width: '100%', maxWidth: 480 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Be the first to be notified when Doormile launches.
|
||||
</Typography>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Button variant="contained" sx={{ whiteSpace: 'nowrap', px: 3 }}>Notify Me</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
<div style={{ width: '100%', maxWidth: '480px', marginTop: '16px' }}>
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<Text type="body" color="secondary">
|
||||
Be the first to be notified when Doormile launches.
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', flexDirection: 'row', alignItems: 'flex-start', flexWrap: 'wrap' }} className="subscribe-row">
|
||||
<div style={{ flexGrow: 1, minWidth: '200px' }}>
|
||||
<TextInput
|
||||
label="Email address"
|
||||
isLabelHidden
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
startIcon={<Mail size={16} />}
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="primary" label="Notify Me" onClick={() => {}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Box, Stack, Typography, Button } from '@mui/material';
|
||||
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Text, Heading } from '@astryxdesign/core/Text';
|
||||
import { Home } from 'lucide-react';
|
||||
|
||||
export default function Error404() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
textAlign: 'center'
|
||||
padding: '24px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
fontFamily: 'system-ui, sans-serif'
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<Typography sx={{ fontWeight: 900, fontSize: { xs: '6rem', md: '9rem' }, lineHeight: 1, color: 'primary.main' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 900, fontSize: 'clamp(3.5rem, 18vw, 8rem)', lineHeight: 1, color: '#0A1317' }}>
|
||||
404
|
||||
</Typography>
|
||||
<Box sx={{ width: 64, height: 4, borderRadius: 2, bgcolor: 'primary.main' }} />
|
||||
<Typography variant="h3" sx={{ fontWeight: 700, color: 'grey.800' }}>Page Not Found</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 460 }}>
|
||||
The page you are looking for was moved, removed, renamed, or might never exist!
|
||||
</Typography>
|
||||
<Button variant="contained" size="large" startIcon={<HomeOutlinedIcon />} onClick={() => navigate('/dashboard')} sx={{ mt: 1 }}>
|
||||
Back To Home
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</span>
|
||||
<div style={{ width: '64px', height: '4px', borderRadius: '2px', backgroundColor: '#0A1317' }} />
|
||||
<Heading level={2}>Page Not Found</Heading>
|
||||
<div style={{ maxWidth: '460px' }}>
|
||||
<Text type="body" color="secondary">
|
||||
The page you are looking for was moved, removed, renamed, or might never exist!
|
||||
</Text>
|
||||
</div>
|
||||
<Button variant="primary" icon={<Home size={16} />} onClick={() => navigate('/dashboard')} label="Back To Home" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Box, Stack, Typography, Button } from '@mui/material';
|
||||
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Text, Heading } from '@astryxdesign/core/Text';
|
||||
import { Home } from 'lucide-react';
|
||||
|
||||
export default function Error500() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
textAlign: 'center'
|
||||
padding: '24px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
fontFamily: 'system-ui, sans-serif'
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<Typography sx={{ fontWeight: 900, fontSize: { xs: '6rem', md: '9rem' }, lineHeight: 1, color: 'primary.main' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 900, fontSize: 'clamp(3.5rem, 18vw, 8rem)', lineHeight: 1, color: '#0A1317' }}>
|
||||
500
|
||||
</Typography>
|
||||
<Box sx={{ width: 64, height: 4, borderRadius: 2, bgcolor: 'primary.main' }} />
|
||||
<Typography variant="h3" sx={{ fontWeight: 700, color: 'grey.800' }}>Internal Server Error</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 460 }}>
|
||||
Server error 500. We are fixing the problem. Please try again at a later stage.
|
||||
</Typography>
|
||||
<Button variant="contained" size="large" startIcon={<HomeOutlinedIcon />} onClick={() => navigate('/dashboard')} sx={{ mt: 1 }}>
|
||||
Back To Home
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</span>
|
||||
<div style={{ width: '64px', height: '4px', borderRadius: '2px', backgroundColor: '#0A1317' }} />
|
||||
<Heading level={2}>Internal Server Error</Heading>
|
||||
<div style={{ maxWidth: '460px' }}>
|
||||
<Text type="body" color="secondary">
|
||||
Server error 500. We are fixing the problem. Please try again at a later stage.
|
||||
</Text>
|
||||
</div>
|
||||
<Button variant="primary" icon={<Home size={16} />} onClick={() => navigate('/dashboard')} label="Back To Home" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Box, Stack, Typography, Button } from '@mui/material';
|
||||
import ConstructionOutlinedIcon from '@mui/icons-material/ConstructionOutlined';
|
||||
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Text, Heading } from '@astryxdesign/core/Text';
|
||||
import { Construction, Home } from 'lucide-react';
|
||||
|
||||
export default function UnderConstruction() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 3,
|
||||
textAlign: 'center'
|
||||
padding: '24px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#f8fafc',
|
||||
fontFamily: 'system-ui, sans-serif'
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2.5} alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
width: 110,
|
||||
height: 110,
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '110px',
|
||||
height: '110px',
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'primary.lighter',
|
||||
color: 'primary.main',
|
||||
backgroundColor: 'rgba(10, 19, 23, 0.08)',
|
||||
color: '#0A1317',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<ConstructionOutlinedIcon sx={{ fontSize: 56 }} />
|
||||
</Box>
|
||||
<Typography variant="h3" sx={{ fontWeight: 700, color: 'grey.800' }}>Under Construction</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 460 }}>
|
||||
Hey! Please check out this site later. We are doing some maintenance on it right now.
|
||||
</Typography>
|
||||
<Button variant="contained" size="large" startIcon={<HomeOutlinedIcon />} onClick={() => navigate('/dashboard')} sx={{ mt: 1 }}>
|
||||
Back To Home
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Construction size={56} />
|
||||
</div>
|
||||
<Heading level={2}>Under Construction</Heading>
|
||||
<div style={{ maxWidth: '460px' }}>
|
||||
<Text type="body" color="secondary">
|
||||
Hey! Please check out this site later. We are doing some maintenance on it right now.
|
||||
</Text>
|
||||
</div>
|
||||
<Button variant="primary" icon={<Home size={16} />} onClick={() => navigate('/dashboard')} label="Back To Home" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
648
src/pages/pricing/Pricing.jsx
Normal file
648
src/pages/pricing/Pricing.jsx
Normal file
@@ -0,0 +1,648 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Plus, Search, Edit, ChevronDown, ChevronUp, MapPin, LayoutGrid } from 'lucide-react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Table, proportional, pixel } from '@astryxdesign/core/Table';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Text, Heading } from '@astryxdesign/core/Text';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||
import { FormLayout } from '@astryxdesign/core/FormLayout';
|
||||
import { Banner } from '@astryxdesign/core/Banner';
|
||||
import { Item } from '@astryxdesign/core/Item';
|
||||
import { Icon } from '@astryxdesign/core/Icon';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { fetchUsers } from '@/utils/apiClient';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || 'https://api.doormile.com/api/v1';
|
||||
|
||||
const getHeaders = () => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
async function apiFetchPricing() {
|
||||
const res = await fetch(`${API_BASE}/admin/carrier-pricing?limit=1000`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error('Failed to fetch pricing');
|
||||
const json = await res.json();
|
||||
return Array.isArray(json) ? { data: json } : json;
|
||||
}
|
||||
|
||||
async function apiSavePricing(data) {
|
||||
const isUpdate = !!data.id;
|
||||
const url = isUpdate ? `${API_BASE}/admin/carrier-pricing/${data.id}` : `${API_BASE}/admin/carrier-pricing`;
|
||||
const method = isUpdate ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({
|
||||
company: data.company,
|
||||
weight_slab: data.weight_slab,
|
||||
zone: data.zone || '',
|
||||
service_type: data.service_type || '',
|
||||
rate: String(data.rate)
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || err.message || 'Failed to save pricing');
|
||||
}
|
||||
return res.status === 204 ? {} : res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
const ZONES = ['Local / Same', 'Within Tamilnadu', 'Interstate India'];
|
||||
const WEIGHT_SLABS = ['< 500g', '500g - 1kg', '1kg - 2kg', '2kg - 5kg', '> 5kg'];
|
||||
|
||||
function PricingFormDialog({ open, onClose, onSave, initialData, isCell = false, pricingList = [], providerColumns = [], providerUsesServiceType = false }) {
|
||||
const [formData, setFormData] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData(initialData || { company: '', weight_slab: '', zone: '', rate: '' });
|
||||
setError(null);
|
||||
}
|
||||
}, [open, initialData]);
|
||||
|
||||
const colKey = (p) => ((p.zone || '').trim() || (p.service_type || '').trim());
|
||||
|
||||
const findExistingRecord = (data) => {
|
||||
const company = (data.company || '').trim();
|
||||
const slab = (data.weight_slab || '').trim();
|
||||
const col = (data.zone || data.service_type || '').trim();
|
||||
return pricingList.find(p =>
|
||||
(p.company || '').trim() === company &&
|
||||
(p.weight_slab || '').trim() === slab &&
|
||||
colKey(p) === col
|
||||
) || null;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const colValue = (formData.zone || formData.service_type || '').trim();
|
||||
if (!isCell) {
|
||||
if (!formData.company?.trim()) { setError('Company is required.'); return; }
|
||||
if (!formData.weight_slab?.trim()) { setError('Weight slab is required.'); return; }
|
||||
if (!colValue) { setError('Zone / Service Type is required.'); return; }
|
||||
}
|
||||
if (!String(formData.rate ?? '').trim()) { setError('Rate is required.'); return; }
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
let dataToSave = { ...formData };
|
||||
if (!isCell) {
|
||||
dataToSave = {
|
||||
...dataToSave,
|
||||
zone: providerUsesServiceType ? '' : colValue,
|
||||
service_type: providerUsesServiceType ? colValue : (dataToSave.service_type || ''),
|
||||
};
|
||||
}
|
||||
if (!dataToSave.id) {
|
||||
const existing = findExistingRecord(dataToSave);
|
||||
if (existing) dataToSave = { ...dataToSave, id: existing.id };
|
||||
}
|
||||
await apiSavePricing(dataToSave);
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err.message || 'Failed to save. Please try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const zoneOptions = providerColumns.length > 0 ? providerColumns : ZONES;
|
||||
|
||||
return (
|
||||
<Dialog isOpen={open} onOpenChange={(o) => { if (!o) onClose(); }} width={440} purpose="form">
|
||||
<Layout
|
||||
header={
|
||||
<DialogHeader
|
||||
title={initialData?.id ? 'Edit Rate' : (isCell ? 'Add Rate' : 'Add New Rate')}
|
||||
onOpenChange={(o) => { if (!o) onClose(); }}
|
||||
/>
|
||||
}
|
||||
content={
|
||||
<LayoutContent>
|
||||
<FormLayout>
|
||||
{error && <Banner status="error" title={error} />}
|
||||
|
||||
{isCell ? (
|
||||
<Card style={{ padding: '12px 16px', backgroundColor: 'rgba(10, 19, 23, 0.04)', border: '1px solid rgba(10, 19, 23, 0.15)' }}>
|
||||
<Text type="label" color="secondary">Updating rate for</Text>
|
||||
<div style={{ marginTop: '4px' }}>
|
||||
<Heading level={5}>{formData.company}</Heading>
|
||||
</div>
|
||||
<div style={{ marginTop: '2px' }}>
|
||||
<Text type="supporting" color="secondary">{formData.weight_slab} · {formData.zone || formData.service_type}</Text>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<TextInput
|
||||
label="Company"
|
||||
isRequired
|
||||
value={formData.company || ''}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, company: v }))}
|
||||
/>
|
||||
<Selector
|
||||
label="Weight Slab"
|
||||
isRequired
|
||||
placeholder="Select Weight Slab"
|
||||
options={WEIGHT_SLABS}
|
||||
value={formData.weight_slab || ''}
|
||||
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 || ''}
|
||||
onChange={(v) => setFormData((prev) => ({
|
||||
...prev,
|
||||
zone: providerUsesServiceType ? '' : (v || ''),
|
||||
service_type: providerUsesServiceType ? (v || '') : ''
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Rate (₹)"
|
||||
isRequired
|
||||
hasClear
|
||||
hasAutoFocus
|
||||
placeholder="e.g. 25 or 25-35"
|
||||
value={formData.rate || ''}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, rate: v }))}
|
||||
/>
|
||||
</FormLayout>
|
||||
</LayoutContent>
|
||||
}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
async function apiFetchSurveys() {
|
||||
const res = await fetch(`${API_BASE}/admin/competitor-branches?limit=1000`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error('Failed to fetch surveys');
|
||||
const json = await res.json();
|
||||
const data = Array.isArray(json) ? json : (json.data || []);
|
||||
return { data, total: data.length };
|
||||
}
|
||||
|
||||
export default function Pricing() {
|
||||
const [pricingList, setPricingList] = useState([]);
|
||||
const [surveysList, setSurveysList] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedProvider, setSelectedProvider] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [locationsOpen, setLocationsOpen] = useState(false);
|
||||
const [locationSearch, setLocationSearch] = useState('');
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState(null);
|
||||
const [isCellEdit, setIsCellEdit] = useState(false);
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
const loadData = () => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
apiFetchPricing().catch(() => ({ data: [] })),
|
||||
apiFetchSurveys().catch(() => ({ data: [] })),
|
||||
fetchUsers().catch(() => [])
|
||||
]).then(([pRes, sRes, userRes]) => {
|
||||
if (!cancelled) {
|
||||
setPricingList(Array.isArray(pRes) ? pRes : (pRes.data || []));
|
||||
setSurveysList(sRes.data || sRes || []);
|
||||
setUsers(Array.isArray(userRes) ? userRes : (userRes.data || []));
|
||||
}
|
||||
}).catch(err => console.error("Failed to fetch data", err))
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return loadData();
|
||||
}, []);
|
||||
|
||||
const companies = [...new Set(pricingList.map(p => p.company).filter(Boolean))];
|
||||
useEffect(() => {
|
||||
if (!selectedProvider && companies.length > 0) {
|
||||
setSelectedProvider(companies[0]);
|
||||
}
|
||||
}, [companies, selectedProvider]);
|
||||
|
||||
const providerRates = pricingList.filter(p => p.company === selectedProvider);
|
||||
const normCompany = (s) => (s || '').toLowerCase().trim().replace(/\s+/g, ' ');
|
||||
const normProvider = normCompany(selectedProvider);
|
||||
const providerSurveys = surveysList.filter(p => {
|
||||
const pn = normCompany(p.company);
|
||||
return pn === normProvider || pn.startsWith(normProvider) || normProvider.startsWith(pn);
|
||||
});
|
||||
|
||||
const filteredSurveys = useMemo(() => {
|
||||
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 slabSortWeight = (slab) => {
|
||||
const s = (slab || '').toLowerCase().replace(/\s+/g, '');
|
||||
const nums = s.match(/[\d.]+/g);
|
||||
if (!nums) return 9999;
|
||||
const first = parseFloat(nums[0]);
|
||||
const grams = s.includes('kg') ? first * 1000 : first;
|
||||
if (s.startsWith('<') || s.startsWith('upto')) return grams - 0.5;
|
||||
if (s.startsWith('>') || s.includes('above') || s.endsWith('+')) return grams + 0.5;
|
||||
return grams;
|
||||
};
|
||||
|
||||
const normalizeZone = (z) => {
|
||||
if (!z) return '';
|
||||
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 &&
|
||||
sortedProviderRates.every(r => !(r.zone || '').trim());
|
||||
|
||||
const providerZones = [...new Set(sortedProviderRates.map(getColKey).filter(Boolean))].sort();
|
||||
|
||||
const allProviderSlabs = [...new Set(sortedProviderRates.map(r => (r.weight_slab || '').trim()).filter(Boolean))]
|
||||
.sort((a, b) => slabSortWeight(a) - slabSortWeight(b));
|
||||
|
||||
const displaySlabs = searchQuery
|
||||
? allProviderSlabs.filter(s => s.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: allProviderSlabs;
|
||||
|
||||
const matrixData = useMemo(
|
||||
() => displaySlabs.map((slab) => ({
|
||||
slab,
|
||||
rates: Object.fromEntries(
|
||||
providerZones.map((zone) => [
|
||||
zone,
|
||||
sortedProviderRates.find(r => (r.weight_slab || '').trim() === slab && getColKey(r) === zone) || null
|
||||
])
|
||||
)
|
||||
})),
|
||||
[displaySlabs, providerZones, sortedProviderRates]
|
||||
);
|
||||
|
||||
const matrixColumns = useMemo(() => {
|
||||
const openCellEdit = (slab, zone, match) => {
|
||||
setEditingRecord({
|
||||
id: match ? match.id : undefined,
|
||||
company: selectedProvider,
|
||||
weight_slab: slab,
|
||||
zone: providerUsesServiceType ? '' : zone,
|
||||
service_type: providerUsesServiceType ? zone : (match?.service_type || ''),
|
||||
rate: match ? String(match.rate) : ''
|
||||
});
|
||||
setIsCellEdit(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 (
|
||||
<div
|
||||
className="matrix-cell"
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4px',
|
||||
borderRadius: '6px',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
{hasRate ? (
|
||||
<Badge variant="red" label={`₹${match.rate}`} />
|
||||
) : (
|
||||
<Text type="supporting" color="disabled">—</Text>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="matrix-cell-edit"
|
||||
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(10, 19, 23, 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(10, 19, 23, 0.08)', color: '#0a1317', 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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 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(10, 19, 23, 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(10, 19, 23, 0.08)', color: '#0A1317', 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 #0A1317', 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
|
||||
open={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
onSave={() => loadData()}
|
||||
initialData={editingRecord}
|
||||
isCell={isCellEdit}
|
||||
pricingList={pricingList}
|
||||
providerColumns={providerZones}
|
||||
providerUsesServiceType={providerUsesServiceType}
|
||||
/>
|
||||
<style>{`
|
||||
/* 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: #0A1317;
|
||||
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(10, 19, 23, 0.08) !important;
|
||||
}
|
||||
.location-card {
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.location-card:hover {
|
||||
border-color: rgba(10, 19, 23, 0.3) !important;
|
||||
box-shadow: 0 4px 12px rgba(10, 19, 23, 0.08);
|
||||
}
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
840
src/pages/survey/Survey.jsx
Normal file
840
src/pages/survey/Survey.jsx
Normal file
@@ -0,0 +1,840 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetchUsers, deleteCompetitorBranch } from '@/utils/apiClient';
|
||||
import {
|
||||
Search,
|
||||
Edit,
|
||||
Trash2,
|
||||
Phone,
|
||||
MapPin,
|
||||
FileText,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Store,
|
||||
Truck,
|
||||
Map,
|
||||
Plus,
|
||||
X,
|
||||
CheckCircle2,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||
import { FormLayout } from '@astryxdesign/core/FormLayout';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { TextArea } from '@astryxdesign/core/TextArea';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { AlertDialog } from '@astryxdesign/core/AlertDialog';
|
||||
import { Text, Heading } from '@astryxdesign/core/Text';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Banner } from '@astryxdesign/core/Banner';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import TablePagination from '@/components/TablePagination';
|
||||
|
||||
function isQuoted(row) {
|
||||
return !!(row.rate_per_kg && !String(row.rate_per_kg).toLowerCase().includes('not answered'));
|
||||
}
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || 'https://api.doormile.com/api/v1';
|
||||
|
||||
const getHeaders = () => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
};
|
||||
|
||||
function Pill({ label, color = 'neutral', truncate = false }) {
|
||||
if (!label) return <span style={{ fontWeight: 500, color: '#94a3b8' }}>—</span>;
|
||||
|
||||
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 (
|
||||
<span
|
||||
title={truncate ? label : undefined}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
maxWidth: truncate ? '100%' : undefined,
|
||||
overflow: truncate ? 'hidden' : undefined,
|
||||
textOverflow: truncate ? 'ellipsis' : undefined,
|
||||
whiteSpace: truncate ? 'nowrap' : undefined,
|
||||
verticalAlign: truncate ? 'bottom' : undefined,
|
||||
boxSizing: 'border-box',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
backgroundColor: style.bg,
|
||||
color: style.fg
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||
<span style={{ textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: '0.62rem', fontWeight: 700, color: '#475569' }}>{label}</span>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ icon: Icon, title, children }) {
|
||||
return (
|
||||
<div style={{ height: '100%', borderRadius: '8px', border: '1px solid #e2e8f0', backgroundColor: '#ffffff', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '6px', padding: '6px 10px', borderBottom: '1px solid #e2e8f0', backgroundColor: 'rgba(10, 19, 23, 0.08)' }}>
|
||||
<Icon size={12} style={{ color: '#0a1317' }} />
|
||||
<span style={{ fontWeight: 700, color: '#1e293b', fontSize: '0.64rem', letterSpacing: '0.6px', textTransform: 'uppercase' }}>{title}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', padding: '8px 10px' }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function apiFetchSurveys() {
|
||||
const res = await fetch(`${API_BASE}/admin/competitor-branches?limit=1000`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error('Failed to fetch surveys');
|
||||
const json = await res.json();
|
||||
const data = Array.isArray(json) ? json : (json.data || []);
|
||||
return { data, total: data.length };
|
||||
}
|
||||
|
||||
async function apiSaveSurvey(data) {
|
||||
const isUpdate = !!data.id;
|
||||
const url = isUpdate ? `${API_BASE}/admin/competitor-branches/${data.id}` : `${API_BASE}/admin/competitor-branches`;
|
||||
const method = isUpdate ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || err.message || 'Failed to save survey');
|
||||
}
|
||||
return res.status === 204 ? {} : res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
async function apiSavePricing(data) {
|
||||
const url = `${API_BASE}/admin/carrier-pricing`;
|
||||
const method = 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({
|
||||
company: data.company,
|
||||
weight_slab: data.weight_slab,
|
||||
zone: data.zone || '',
|
||||
service_type: data.service_type || '',
|
||||
rate: String(data.rate)
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || err.message || 'Failed to save pricing');
|
||||
}
|
||||
return res.status === 204 ? {} : res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
const ZONES = ['Local / Same', 'Within Tamilnadu', 'Interstate India'];
|
||||
const WEIGHT_SLABS = ['< 500g', '500g - 1kg', '1kg - 2kg', '2kg - 5kg', '> 5kg'];
|
||||
|
||||
function SurveyFormDialog({ open, onClose, onSave, initialData }) {
|
||||
const [formData, setFormData] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData(initialData ? {
|
||||
...initialData,
|
||||
pincodes: initialData.pincodes ? initialData.pincodes.split(',').map(s => s.trim()).filter(Boolean) : []
|
||||
} : {
|
||||
company: '', area: '', phone: '', rate_per_kg: '',
|
||||
offers_pickup: 'no', offers_drop: 'no', packing_charge: '',
|
||||
time_in_days: '', plus_code: '', address: '', frequency: '',
|
||||
pincodes: [],
|
||||
slabs: []
|
||||
});
|
||||
setError(null);
|
||||
}
|
||||
}, [open, initialData]);
|
||||
|
||||
const set = (field) => (val) => {
|
||||
setFormData(prev => ({ ...prev, [field]: val }));
|
||||
};
|
||||
|
||||
const handleSlabChange = (index, field) => (val) => {
|
||||
const newSlabs = [...(formData.slabs || [])];
|
||||
newSlabs[index] = { ...newSlabs[index], [field]: val };
|
||||
setFormData(prev => ({ ...prev, slabs: newSlabs }));
|
||||
};
|
||||
|
||||
const addSlab = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
slabs: [...(prev.slabs || []), { weight_slab: '', zone: '', service_type: '', rate: '' }]
|
||||
}));
|
||||
};
|
||||
|
||||
const removeSlab = (index) => {
|
||||
const newSlabs = [...(formData.slabs || [])];
|
||||
newSlabs.splice(index, 1);
|
||||
setFormData(prev => ({ ...prev, slabs: newSlabs }));
|
||||
};
|
||||
|
||||
const handlePincodeChange = (index) => (val) => {
|
||||
const newPincodes = [...(formData.pincodes || [])];
|
||||
newPincodes[index] = val;
|
||||
setFormData(prev => ({ ...prev, pincodes: newPincodes }));
|
||||
};
|
||||
|
||||
const addPincode = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
pincodes: [...(prev.pincodes || []), '']
|
||||
}));
|
||||
};
|
||||
|
||||
const removePincode = (index) => {
|
||||
const newPincodes = [...(formData.pincodes || [])];
|
||||
newPincodes.splice(index, 1);
|
||||
setFormData(prev => ({ ...prev, pincodes: newPincodes }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (formData.pincodes && formData.pincodes.length > 0) {
|
||||
const invalidPincodes = formData.pincodes.filter(p => p.trim() && !/^\d{6}$/.test(p.trim()));
|
||||
if (invalidPincodes.length > 0) {
|
||||
throw new Error(`Invalid pincodes: ${invalidPincodes.join(', ')}. Must be 6 digits.`);
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...(formData.id ? { id: formData.id } : {}),
|
||||
company: formData.company || '',
|
||||
area: formData.area || '',
|
||||
phone: formData.phone || '',
|
||||
rate_per_kg: formData.rate_per_kg || '',
|
||||
offers_pickup: formData.offers_pickup || 'no',
|
||||
offers_drop: formData.offers_drop || 'no',
|
||||
packing_charge: formData.packing_charge || '',
|
||||
time_in_days: formData.time_in_days || '',
|
||||
plus_code: formData.plus_code || '',
|
||||
address: formData.address || '',
|
||||
frequency: formData.frequency || '',
|
||||
pincodes: formData.pincodes ? formData.pincodes.join(',') : '',
|
||||
};
|
||||
await apiSaveSurvey(payload);
|
||||
|
||||
if (formData.slabs && formData.slabs.length > 0 && formData.company) {
|
||||
try {
|
||||
await Promise.all(formData.slabs.map(slab => {
|
||||
if (slab.weight_slab && slab.rate) {
|
||||
return apiSavePricing({
|
||||
company: formData.company,
|
||||
weight_slab: slab.weight_slab,
|
||||
zone: slab.zone || '',
|
||||
service_type: slab.service_type || '',
|
||||
rate: slab.rate
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("Failed to save pricing slabs", err);
|
||||
}
|
||||
}
|
||||
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err.message || 'Failed to save. Please try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const YES_NO = [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }];
|
||||
const FREQUENCY_OPTIONS = ['Daily', 'Weekly', 'Bi-weekly', 'Monthly', 'On-demand'];
|
||||
const WEIGHT_SLAB_OPTIONS = WEIGHT_SLABS.map((s) => ({ value: s, label: s }));
|
||||
const ZONE_OPTIONS = ZONES.map((z) => ({ value: z, label: z }));
|
||||
|
||||
return (
|
||||
<Dialog isOpen={open} onOpenChange={(o) => { if (!o) onClose(); }} width={600} maxHeight="90dvh" purpose="form">
|
||||
<Layout
|
||||
header={<DialogHeader title={initialData ? 'Edit Survey Record' : 'Add New Survey'} onOpenChange={(o) => { if (!o) onClose(); }} />}
|
||||
content={
|
||||
<LayoutContent>
|
||||
<FormLayout>
|
||||
{error && <Banner status="error" title={error} />}
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput label="Company" value={formData.company || ''} onChange={set('company')} />
|
||||
<TextInput label="Area / Zone" value={formData.area || ''} onChange={set('area')} />
|
||||
</FormLayout>
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput label="Phone" value={formData.phone || ''} onChange={set('phone')} />
|
||||
<TextInput label="Rate Per KG" value={formData.rate_per_kg || ''} onChange={set('rate_per_kg')} />
|
||||
</FormLayout>
|
||||
<FormLayout direction="horizontal">
|
||||
<Selector label="Offers Pickup" options={YES_NO} value={formData.offers_pickup || 'no'} onChange={set('offers_pickup')} />
|
||||
<Selector label="Offers Drop" options={YES_NO} value={formData.offers_drop || 'no'} onChange={set('offers_drop')} />
|
||||
</FormLayout>
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput label="Time in Days" value={formData.time_in_days || ''} onChange={set('time_in_days')} />
|
||||
<TextInput label="Packing Charge" value={formData.packing_charge || ''} onChange={set('packing_charge')} />
|
||||
</FormLayout>
|
||||
<FormLayout direction="horizontal">
|
||||
<Selector
|
||||
label="Frequency"
|
||||
placeholder="Select Frequency"
|
||||
hasClear
|
||||
options={FREQUENCY_OPTIONS}
|
||||
value={formData.frequency || ''}
|
||||
onChange={(v) => set('frequency')(v ?? '')}
|
||||
/>
|
||||
<TextInput label="Plus Code" value={formData.plus_code || ''} onChange={set('plus_code')} />
|
||||
</FormLayout>
|
||||
|
||||
<div style={{ borderTop: '1px dashed #e2e8f0', paddingTop: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: '#0A1317' }}>SERVICEABLE PINCODES</span>
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={12} />} onClick={addPincode} label="Add Pincode" />
|
||||
</div>
|
||||
{(formData.pincodes || []).map((pincode, index) => (
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-end', marginBottom: '8px' }} key={index}>
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
<TextInput
|
||||
label={`Pincode ${index + 1}`}
|
||||
isLabelHidden
|
||||
placeholder={`Pincode ${index + 1}`}
|
||||
value={pincode}
|
||||
onChange={handlePincodeChange(index)}
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={() => removePincode(index)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#dc2626', padding: '8px' }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<TextArea label="Address" rows={2} value={formData.address || ''} onChange={set('address')} />
|
||||
|
||||
{/* Pricing slabs */}
|
||||
<div style={{ borderTop: '1px dashed #e2e8f0', paddingTop: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: '#0A1317' }}>PRICING SLABS (OPTIONAL)</span>
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={12} />} onClick={addSlab} label="Add Slab" />
|
||||
</div>
|
||||
{(formData.slabs || []).map((slab, index) => (
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-end', marginBottom: '8px', flexWrap: 'wrap' }} key={index}>
|
||||
<div style={{ width: '140px' }}>
|
||||
<Selector
|
||||
label="Weight Slab" isLabelHidden placeholder="Weight Slab" hasClear
|
||||
options={WEIGHT_SLAB_OPTIONS} value={slab.weight_slab || ''}
|
||||
onChange={(v) => handleSlabChange(index, 'weight_slab')(v ?? '')}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: '140px' }}>
|
||||
<Selector
|
||||
label="Zone" isLabelHidden placeholder="Zone" hasClear
|
||||
options={ZONE_OPTIONS} value={slab.zone || ''}
|
||||
onChange={(v) => handleSlabChange(index, 'zone')(v ?? '')}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flexGrow: 1, minWidth: '140px' }}>
|
||||
<TextInput label="Service Type" isLabelHidden placeholder="Service Type" value={slab.service_type || ''} onChange={handleSlabChange(index, 'service_type')} />
|
||||
</div>
|
||||
<div style={{ width: '90px' }}>
|
||||
<TextInput label="Rate" isLabelHidden placeholder="Rate" value={slab.rate || ''} onChange={handleSlabChange(index, 'rate')} />
|
||||
</div>
|
||||
<button onClick={() => removeSlab(index)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#dc2626', padding: '8px' }}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FormLayout>
|
||||
</LayoutContent>
|
||||
}
|
||||
footer={
|
||||
<LayoutFooter hasDivider>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||
<Button label="Cancel" variant="ghost" onClick={onClose} isDisabled={saving} />
|
||||
<Button
|
||||
label="Save Record"
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
isDisabled={saving}
|
||||
isLoading={saving}
|
||||
/>
|
||||
</div>
|
||||
</LayoutFooter>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchCard({ row, onEdit, onDelete, users }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const creator = users.find(u => u.id === row.created_by);
|
||||
const updater = users.find(u => u.id === row.updated_by);
|
||||
const quoted = isQuoted(row);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderLeft: `3px solid ${quoted ? '#10b981' : '#f59e0b'}`,
|
||||
backgroundColor: '#ffffff',
|
||||
transition: 'border-color 0.2s, box-shadow 0.2s',
|
||||
marginBottom: '6px'
|
||||
}}
|
||||
className="branch-card"
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'nowrap', gap: '10px', overflow: 'hidden' }} onClick={() => setOpen(!open)}>
|
||||
|
||||
{/* Left: Location & Contact */}
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', minWidth: '160px', flexShrink: 0 }}>
|
||||
<div style={{ padding: '6px', borderRadius: '6px', backgroundColor: 'rgba(10, 19, 23, 0.05)', color: '#0a1317', display: 'flex' }}>
|
||||
<MapPin size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, color: '#1e293b', fontSize: '0.8rem', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
{row.area || 'Unknown Area'}
|
||||
{quoted ? (
|
||||
<CheckCircle2 size={11} style={{ color: '#10b981', flexShrink: 0 }} />
|
||||
) : (
|
||||
<AlertCircle size={11} style={{ color: '#f59e0b', flexShrink: 0 }} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color: '#64748b', fontSize: '0.72rem', display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px' }}>
|
||||
<Phone size={10} /> {row.phone || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle: Rates */}
|
||||
<div style={{ display: 'flex', gap: '16px', flexGrow: 1, overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, overflow: 'hidden', minWidth: 0 }}>
|
||||
<span style={{ fontSize: '0.6rem', color: '#64748b', fontWeight: 700, letterSpacing: '0.05em', display: 'block', marginBottom: '2px', textTransform: 'uppercase' }}>Rate/kg</span>
|
||||
<Pill label={row.rate_per_kg} color={quoted ? 'success' : 'warning'} truncate />
|
||||
</div>
|
||||
<div style={{ width: '90px', flexShrink: 0, overflow: 'hidden' }}>
|
||||
<span style={{ fontSize: '0.6rem', color: '#64748b', fontWeight: 700, letterSpacing: '0.05em', display: 'block', marginBottom: '2px', textTransform: 'uppercase' }}>Logistics</span>
|
||||
<span style={{ fontWeight: 600, color: '#1e293b', fontSize: '0.8rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{row.offers_pickup === 'yes' || row.offers_drop === 'yes' ? `${row.offers_pickup === 'yes' ? 'Pickup' : ''} ${row.offers_drop === 'yes' ? 'Drop' : ''}` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }} onClick={(e) => e.stopPropagation()}>
|
||||
<Button size="sm" variant="secondary" label={open ? 'Hide Info' : 'More Info'} onClick={() => setOpen(!open)} />
|
||||
<button onClick={() => onEdit(row)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '5px', borderRadius: '4px', color: '#475569' }} className="action-btn">
|
||||
<Edit size={14} />
|
||||
</button>
|
||||
<button onClick={() => onDelete(row)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '5px', borderRadius: '4px', color: '#dc2626' }} className="action-btn">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px dashed #e2e8f0' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '8px' }}>
|
||||
<SectionCard icon={Store} title="ENQUIRY DETAILS">
|
||||
<Field label="RATE PER KG">
|
||||
<Pill label={row.rate_per_kg} color="success" />
|
||||
</Field>
|
||||
<Field label="PICKUP">
|
||||
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.offers_pickup || '—'}</span>
|
||||
</Field>
|
||||
<Field label="DROP">
|
||||
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.offers_drop || '—'}</span>
|
||||
</Field>
|
||||
<Field label="PACKING">
|
||||
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.packing_charge || '—'}</span>
|
||||
</Field>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={Truck} title="LOGISTICS & OPS">
|
||||
<Field label="TIME IN DAYS">
|
||||
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.time_in_days || '—'}</span>
|
||||
</Field>
|
||||
<Field label="COMPANY">
|
||||
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.company || '—'}</span>
|
||||
</Field>
|
||||
<Field label="FREQUENCY">
|
||||
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.frequency || '—'}</span>
|
||||
</Field>
|
||||
<Field label="CONTACT NUMBER">
|
||||
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.phone || '—'}</span>
|
||||
</Field>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={MapPin} title="LOCATION & PINCODE">
|
||||
<Field label="AREA / ZONE">
|
||||
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.area || '—'}</span>
|
||||
</Field>
|
||||
<Field label="SERVICEABLE PINCODES">
|
||||
{row.pincodes ? (
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', marginTop: '4px' }}>
|
||||
{row.pincodes.split(',').map((p, i) => (
|
||||
<span key={i} style={{ fontSize: '0.7rem', padding: '2px 6px', backgroundColor: '#f1f5f9', color: '#475569', borderRadius: '4px' }}>{p.trim()}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.78rem', color: '#94a3b8' }}>—</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="PLUS CODE">
|
||||
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.plus_code || '—'}</span>
|
||||
</Field>
|
||||
<Field label="FULL ADDRESS">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px', padding: '8px', borderRadius: '6px', backgroundColor: '#f8fafc', border: '1px solid #e2e8f0' }}>
|
||||
<MapPin size={12} style={{ color: '#94a3b8', marginTop: '2px' }} />
|
||||
<span style={{ color: '#1e293b', fontSize: '0.78rem', lineHeight: 1.4 }}>{row.address || '—'}</span>
|
||||
</div>
|
||||
{(row.address || row.plus_code) && (
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(row.plus_code ? row.plus_code + ' ' + (row.address||'') : row.address)}`}
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
style={{
|
||||
alignSelf: 'flex-start',
|
||||
padding: '4px 10px',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: '4px',
|
||||
color: '#0a1317',
|
||||
border: '1px solid rgba(10, 19, 23, 0.2)',
|
||||
backgroundColor: 'rgba(10, 19, 23, 0.05)',
|
||||
textDecoration: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
>
|
||||
<Map size={12} />
|
||||
View on map
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<SectionCard icon={FileText} title="RECORD METADATA">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '8px' }}>
|
||||
<Field label="CREATED BY">
|
||||
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>
|
||||
{creator ? creator.first_name : (row.created_by ? `User ID: ${row.created_by}` : 'System')}
|
||||
</span>
|
||||
</Field>
|
||||
<Field label="LAST EDITED BY">
|
||||
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>
|
||||
{updater ? updater.first_name : (row.updated_by ? `User ID: ${row.updated_by}` : '—')}
|
||||
</span>
|
||||
</Field>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompanyGroup({ companyName, branches, onEdit, onDelete, users, isLast }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const quotedCount = branches.filter(isQuoted).length;
|
||||
const totalCount = branches.length;
|
||||
const allQuoted = quotedCount === totalCount;
|
||||
const noneQuoted = quotedCount === 0;
|
||||
const quotedVariant = allQuoted ? 'success' : noneQuoted ? 'error' : 'warning';
|
||||
|
||||
return (
|
||||
<div style={{ borderBottom: isLast ? 'none' : '1px solid rgba(5, 54, 89, 0.06)' }}>
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '12px', padding: '10px 16px', cursor: 'pointer' }}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<div style={{ width: '32px', height: '32px', borderRadius: '8px', backgroundColor: 'rgba(10, 19, 23, 0.08)', color: '#0A1317', fontWeight: 700, fontSize: '0.8rem', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
{(companyName || 'A')[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<Text type="body" weight="semibold">{companyName || 'Unknown Company'}</Text>
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', marginTop: '2px', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||
<Store size={11} style={{ color: '#94a3b8' }} />
|
||||
<Text type="supporting" color="secondary">{branches.length} location{branches.length !== 1 && 's'}</Text>
|
||||
</div>
|
||||
<Badge
|
||||
variant={quotedVariant}
|
||||
icon={allQuoted ? <CheckCircle2 size={11} /> : <AlertCircle size={11} />}
|
||||
label={`${quotedCount}/${totalCount} quoted`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
label={open ? 'Hide locations' : 'View locations'}
|
||||
tooltip={open ? 'Hide locations' : 'View locations'}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={open ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div style={{ padding: '0 16px 10px 16px', backgroundColor: '#f8fafc' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', paddingTop: '4px' }}>
|
||||
{branches.map((branch, idx) => <BranchCard key={branch.id ?? idx} row={branch} onEdit={onEdit} onDelete={onDelete} users={users} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Survey() {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(5);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState(null);
|
||||
const [toDelete, setToDelete] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState(null);
|
||||
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
const loadData = () => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
apiFetchSurveys(),
|
||||
fetchUsers().catch(() => [])
|
||||
])
|
||||
.then(([res, userRes]) => {
|
||||
if (!cancelled) {
|
||||
setData(res.data || res || []);
|
||||
setUsers(userRes || []);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Error fetching data:", err))
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return loadData();
|
||||
}, []);
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setDeleting(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await deleteCompetitorBranch(toDelete.id);
|
||||
setToDelete(null);
|
||||
loadData();
|
||||
} catch (e) {
|
||||
setDeleteError(e.message || 'Failed to delete. Please try again.');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = data.filter(r =>
|
||||
(r.company || '').toLowerCase().includes(search.toLowerCase()) ||
|
||||
(r.area || '').toLowerCase().includes(search.toLowerCase()) ||
|
||||
(r.phone || '').toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const groupedData = filtered.reduce((acc, row) => {
|
||||
const comp = row.company || 'Unknown Company';
|
||||
if (!acc[comp]) acc[comp] = [];
|
||||
acc[comp].push(row);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const companyKeys = Object.keys(groupedData).sort();
|
||||
const paginatedKeys = companyKeys.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
|
||||
|
||||
const stats = {
|
||||
total: data.length,
|
||||
quoted: data.filter(d => d.rate_per_kg && !String(d.rate_per_kg).toLowerCase().includes('not answered')).length,
|
||||
missing: data.filter(d => !d.rate_per_kg || String(d.rate_per_kg).toLowerCase().includes('not answered')).length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
||||
<PageHeader
|
||||
title="Field Surveys"
|
||||
action={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'nowrap' }}>
|
||||
<div style={{ flex: '0 1 320px', minWidth: '200px' }}>
|
||||
<TextInput
|
||||
label="Search survey records"
|
||||
isLabelHidden
|
||||
placeholder="Search surveys…"
|
||||
startIcon={<Search size={16} />}
|
||||
hasClear
|
||||
value={search}
|
||||
onChange={(v) => { setSearch(v); setPage(0); }}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={() => { setEditingRecord(null); setFormOpen(true); }}
|
||||
label="Add New Survey"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px', marginTop: '16px' }}>
|
||||
<StatCard
|
||||
size="sm"
|
||||
title="TOTAL ENQUIRIES"
|
||||
value={loading ? '...' : stats.total}
|
||||
icon={FileText}
|
||||
color="primary"
|
||||
/>
|
||||
<StatCard
|
||||
size="sm"
|
||||
title="RATES QUOTED"
|
||||
value={loading ? '...' : stats.quoted}
|
||||
icon={Store}
|
||||
color="success"
|
||||
/>
|
||||
<StatCard
|
||||
size="sm"
|
||||
title="MISSING INFO"
|
||||
value={loading ? '...' : stats.missing}
|
||||
icon={Phone}
|
||||
color="warning"
|
||||
/>
|
||||
</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)' }}>
|
||||
|
||||
{/* List Section */}
|
||||
{loading ? (
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="skeleton-pulse" style={{ height: '64px', borderRadius: '10px', backgroundColor: '#f1f5f9', marginBottom: '10px' }} />
|
||||
))}
|
||||
</div>
|
||||
) : paginatedKeys.length > 0 ? (
|
||||
<div>
|
||||
{paginatedKeys.map((companyName, idx) => (
|
||||
<CompanyGroup
|
||||
key={companyName}
|
||||
companyName={companyName}
|
||||
branches={groupedData[companyName]}
|
||||
onEdit={(row) => { setEditingRecord(row); setFormOpen(true); }}
|
||||
onDelete={(row) => setToDelete(row)}
|
||||
users={users}
|
||||
isLast={idx === paginatedKeys.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : search ? (
|
||||
<div style={{ padding: '32px 24px' }}>
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title="No matching records"
|
||||
caption={`Nothing matches "${search}". Try a different company, area or phone number.`}
|
||||
/>
|
||||
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'center' }}>
|
||||
<Button label="Clear search" variant="secondary" onClick={() => { setSearch(''); setPage(0); }} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '32px 24px' }}>
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
title="No survey records yet"
|
||||
caption="Field surveys you add will show up here, grouped by company."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TablePagination page={page} rpp={rowsPerPage} total={companyKeys.length} onPageChange={setPage} onRppChange={setRowsPerPage} unitLabel="Companies" />
|
||||
</Card>
|
||||
|
||||
<SurveyFormDialog
|
||||
open={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
onSave={() => loadData()}
|
||||
initialData={editingRecord}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
isOpen={!!toDelete}
|
||||
onOpenChange={(o) => { if (!o) { setToDelete(null); setDeleteError(null); } }}
|
||||
title="Delete survey record?"
|
||||
description={
|
||||
toDelete
|
||||
? (deleteError || `This will permanently remove the record for ${toDelete.area || toDelete.company}. This cannot be undone.`)
|
||||
: ''
|
||||
}
|
||||
actionLabel="Delete"
|
||||
onAction={confirmDelete}
|
||||
isActionLoading={deleting}
|
||||
/>
|
||||
|
||||
<style>{`
|
||||
.action-btn:hover {
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
.branch-card:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
@keyframes skeletonPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
|
||||
.skeleton-pulse { animation: skeletonPulse 1.4s ease-in-out infinite; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/pages/survey/data.json
Normal file
1
src/pages/survey/data.json
Normal file
File diff suppressed because one or more lines are too long
@@ -1,145 +1,72 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Card, Stack, Button, TextField, InputAdornment, Box, Tabs, Tab, Chip, Link,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton,
|
||||
TablePagination, Typography, CircularProgress, Alert, Tooltip, Divider, useMediaQuery,
|
||||
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions
|
||||
} from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import MailOutlineIcon from '@mui/icons-material/MailOutline';
|
||||
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined';
|
||||
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
||||
import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined';
|
||||
import BadgeOutlinedIcon from '@mui/icons-material/BadgeOutlined';
|
||||
import SupportAgentOutlinedIcon from '@mui/icons-material/SupportAgentOutlined';
|
||||
import ManageAccountsOutlinedIcon from '@mui/icons-material/ManageAccountsOutlined';
|
||||
import PersonOutlineOutlinedIcon from '@mui/icons-material/PersonOutlineOutlined';
|
||||
Search,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Mail,
|
||||
Phone,
|
||||
ShieldAlert,
|
||||
Contact,
|
||||
Headphones,
|
||||
Settings,
|
||||
User
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Table, proportional, pixel } from '@astryxdesign/core/Table';
|
||||
import { AlertDialog } from '@astryxdesign/core/AlertDialog';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Banner } from '@astryxdesign/core/Banner';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import TabLabelCount from '@/components/TabLabelCount';
|
||||
import { fetchPoints, deletePoint, COLLECTIONS } from '@/utils/qdrant';
|
||||
import TablePagination from '@/components/TablePagination';
|
||||
import { fetchUsers, deleteUser } from '@/utils/apiClient';
|
||||
import { toUser } from '@/utils/mappers';
|
||||
import { titleCase } from '@/utils/format';
|
||||
import UserFormDialog from './UserFormDialog';
|
||||
|
||||
// Map a raw Qdrant point from doormile_auth to a flat team-user row.
|
||||
function toUser(point) {
|
||||
const p = point.payload || {};
|
||||
return {
|
||||
id: point.id,
|
||||
name: p.name || '—',
|
||||
email: p.email || '',
|
||||
phone: p.phone || '',
|
||||
role: p.role || 'unknown',
|
||||
pin: p.pin || ''
|
||||
};
|
||||
}
|
||||
|
||||
const titleCase = (s) => String(s || '').replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
// Per-role accent colour + icon, used for the avatar badge and role chip.
|
||||
const ROLE_META = {
|
||||
admin: { color: 'primary', icon: AdminPanelSettingsOutlinedIcon },
|
||||
manager: { color: 'success', icon: ManageAccountsOutlinedIcon },
|
||||
rep: { color: 'info', icon: BadgeOutlinedIcon },
|
||||
support: { color: 'warning', icon: SupportAgentOutlinedIcon }
|
||||
admin: { color: 'primary', icon: ShieldAlert },
|
||||
manager: { color: 'success', icon: Settings },
|
||||
rep: { color: 'info', icon: Contact },
|
||||
support: { color: 'warning', icon: Headphones }
|
||||
};
|
||||
const roleMeta = (role) => ROLE_META[String(role || '').toLowerCase()] || { color: 'secondary', icon: PersonOutlineOutlinedIcon };
|
||||
const roleMeta = (role) => ROLE_META[String(role || '').toLowerCase()] || { color: 'secondary', icon: User };
|
||||
|
||||
// Avatar with a small role-coloured badge in the corner.
|
||||
function UserIdentity({ name, email, role }) {
|
||||
const { color, icon: RoleIcon } = roleMeta(role);
|
||||
// Name + handle only — no avatar (role is already shown in its own column).
|
||||
function UserIdentity({ name, email }) {
|
||||
const handle = email ? email.split('@')[0] : '';
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<UserAvatar name={name} size={40} sx={{ border: '2px solid', borderColor: `${color}.lighter` }} />
|
||||
<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>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, color: '#1e293b', fontSize: '0.8rem', lineHeight: 1.2 }}>{name}</div>
|
||||
{handle && <div style={{ fontSize: '0.68rem', color: '#64748b' }}>@{handle}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ROLE_BADGE_VARIANT = {
|
||||
primary: 'red',
|
||||
success: 'success',
|
||||
info: 'info',
|
||||
warning: 'warning',
|
||||
secondary: 'neutral'
|
||||
};
|
||||
|
||||
function RoleCell({ role }) {
|
||||
const { color, icon: RoleIcon } = roleMeta(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>
|
||||
);
|
||||
const { color } = roleMeta(role);
|
||||
return <Badge variant={ROLE_BADGE_VARIANT[color] || 'neutral'} label={titleCase(role)} />;
|
||||
}
|
||||
|
||||
export default function TeamUsers() {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -153,13 +80,25 @@ export default function TeamUsers() {
|
||||
const [toDelete, setToDelete] = useState(null);
|
||||
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 requestId = ++loadRequestId.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchPoints(COLLECTIONS.teamUsers)
|
||||
.then((points) => setUsers(points.map(toUser)))
|
||||
.catch((e) => setError(e.message || 'Failed to load team users'))
|
||||
.finally(() => setLoading(false));
|
||||
fetchUsers()
|
||||
.then((points) => {
|
||||
if (loadRequestId.current === requestId) setUsers(points.map(toUser));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (loadRequestId.current === requestId) setError(e.message || 'Failed to load team users');
|
||||
})
|
||||
.finally(() => {
|
||||
if (loadRequestId.current === requestId) setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
@@ -190,13 +129,106 @@ export default function TeamUsers() {
|
||||
);
|
||||
|
||||
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' }}>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: '0 12px' }}>User</div>,
|
||||
width: proportional(2),
|
||||
renderCell: (r) => (
|
||||
<div style={{ padding: '2px 12px' }}>
|
||||
<UserIdentity name={r.name} email={r.email} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
header: <div style={{ padding: '0 12px' }}>Email</div>,
|
||||
width: proportional(2),
|
||||
renderCell: (r) => (
|
||||
<div style={{ padding: '2px 12px' }}>
|
||||
{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: '0 12px' }}>Phone</div>,
|
||||
width: proportional(1),
|
||||
renderCell: (r) => (
|
||||
<div style={{ padding: '2px 12px' }}>
|
||||
{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: '0 12px' }}>Role</div>,
|
||||
width: pixel(140),
|
||||
renderCell: (r) => (
|
||||
<div style={{ padding: '2px 12px' }}>
|
||||
<RoleCell role={r.role} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: <div style={{ paddingRight: '24px' }}>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 confirmDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deletePoint(COLLECTIONS.teamUsers, toDelete.id);
|
||||
await deleteUser(toDelete.id);
|
||||
setToDelete(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
@@ -207,125 +239,89 @@ export default function TeamUsers() {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
||||
<PageHeader
|
||||
title="App Users"
|
||||
breadcrumbs={[{ label: 'App Users' }]}
|
||||
title="Team Users"
|
||||
action={
|
||||
<Stack direction="row" spacing={1.5} sx={{ width: { xs: '100%', sm: 'auto' } }}>
|
||||
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={load} disabled={loading} sx={{ flex: { xs: 1, sm: 'none' } }}>Refresh</Button>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setDialog({ open: true, mode: 'add', initial: null })} sx={{ flex: { xs: 1, sm: 'none' } }}>Add User</Button>
|
||||
</Stack>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'nowrap' }}>
|
||||
<div style={{ flex: '0 1 320px', minWidth: '200px' }}>
|
||||
<TextInput
|
||||
label="Search team users"
|
||||
isLabelHidden
|
||||
placeholder="Search team users…"
|
||||
startIcon={<Search size={16} />}
|
||||
hasClear
|
||||
value={search}
|
||||
onChange={(v) => { setSearch(v); setPage(0); }}
|
||||
htmlName="team-users-search"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={() => setDialog({ open: true, mode: 'add', initial: null })}
|
||||
label="Add User"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card sx={{ overflow: 'hidden' }}>
|
||||
<Box
|
||||
sx={{
|
||||
px: { xs: 1.75, sm: 2.5 }, py: 2, borderBottom: 1, borderColor: 'divider',
|
||||
display: 'flex', alignItems: 'center', gap: 1.5,
|
||||
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}66 0%, ${theme.palette.background.paper} 70%)`
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 40, height: 40, borderRadius: 2, bgcolor: 'primary.lighter', color: 'primary.main', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<GroupsOutlinedIcon fontSize="small" />
|
||||
</Box>
|
||||
<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>
|
||||
<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)' }}>
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', padding: '0 24px', borderBottom: '1px solid rgba(5, 54, 89, 0.06)', overflowX: 'auto', gap: '24px' }}>
|
||||
{tabs.map((t, i) => (
|
||||
<div
|
||||
key={t.key}
|
||||
onClick={() => { setTab(i); setPage(0); }}
|
||||
style={{
|
||||
padding: '16px 0',
|
||||
cursor: 'pointer',
|
||||
borderBottom: tab === i ? '2px solid #0A1317' : '2px solid transparent',
|
||||
color: tab === i ? '#0A1317' : '#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} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: { xs: 1.5, sm: 2 } }} alignItems={{ md: 'center' }}>
|
||||
<TextField
|
||||
size="small" placeholder="Search by name, email, phone…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
sx={{ width: { xs: '100%', md: 300 } }}
|
||||
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 ? 'user' : 'users'}
|
||||
</Typography>
|
||||
<Chip size="small" label="live · doormile_auth" 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>}
|
||||
{error && (
|
||||
<div style={{ padding: '20px 24px 0' }}>
|
||||
<Banner
|
||||
status="error"
|
||||
title={error}
|
||||
endContent={<Button label="Retry" variant="ghost" size="sm" onClick={load} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px 0' }}>
|
||||
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #0A1317', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||
</div>
|
||||
) : paged.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table sx={{ minWidth: 800 }}>
|
||||
<TableHead>
|
||||
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', textTransform: 'uppercase', fontSize: '0.72rem', letterSpacing: 0.4 } }}>
|
||||
<TableCell sx={{ width: 64 }}>S.No</TableCell>
|
||||
<TableCell>User</TableCell>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Phone</TableCell>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</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>
|
||||
<div style={{ paddingTop: '8px', paddingBottom: '8px' }}>
|
||||
<Table
|
||||
data={displayRows}
|
||||
columns={columns}
|
||||
idKey="id"
|
||||
density="compact"
|
||||
dividers="rows"
|
||||
hasHover
|
||||
isStriped
|
||||
textOverflow="truncate"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<TablePagination
|
||||
component="div" count={filtered.length} page={page} onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[5, 10, 25]}
|
||||
/>
|
||||
|
||||
<TablePagination page={page} rpp={rpp} total={filtered.length} onPageChange={setPage} onRppChange={setRpp} />
|
||||
</Card>
|
||||
|
||||
<UserFormDialog
|
||||
@@ -336,18 +332,22 @@ export default function TeamUsers() {
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
|
||||
<Dialog open={!!toDelete} onClose={deleting ? undefined : () => setToDelete(null)}>
|
||||
<DialogTitle>Delete user?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This will permanently remove <strong>{toDelete?.name}</strong> from the doormile_auth collection. This cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<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>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
<AlertDialog
|
||||
isOpen={!!toDelete}
|
||||
onOpenChange={(o) => { if (!o) setToDelete(null); }}
|
||||
title="Delete user?"
|
||||
description={toDelete ? `This will permanently remove ${toDelete.name} from the PostgreSQL database. This cannot be undone.` : ''}
|
||||
actionLabel="Delete"
|
||||
onAction={confirmDelete}
|
||||
isActionLoading={deleting}
|
||||
/>
|
||||
|
||||
<style>{`
|
||||
.link-hover:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, TextField,
|
||||
MenuItem, Alert, CircularProgress, IconButton, useMediaQuery
|
||||
} from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { createPoint, setPayload, COLLECTIONS } from '@/utils/qdrant';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||
import { FormLayout } from '@astryxdesign/core/FormLayout';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Banner } from '@astryxdesign/core/Banner';
|
||||
|
||||
import { createUser, updateUser } from '@/utils/apiClient';
|
||||
|
||||
const ROLES = ['admin', 'rep', 'manager'];
|
||||
|
||||
const EMPTY = { name: '', email: '', phone: '', role: 'rep', pin: '' };
|
||||
const EMPTY = { name: '', email: '', phone: '', role: 'rep', pin: '', password: '' };
|
||||
|
||||
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 }) {
|
||||
const isEdit = mode === 'edit';
|
||||
const theme = useTheme();
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const set = (k) => (val) => setForm((f) => ({ ...f, [k]: val }));
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setForm({ ...EMPTY, ...(initial || {}) });
|
||||
@@ -28,27 +30,27 @@ export default function UserFormDialog({ open, mode, initial, onClose, onSaved }
|
||||
}
|
||||
}, [open, initial]);
|
||||
|
||||
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) { setError('Name is required.'); return; }
|
||||
if (!form.email.trim()) { setError('Email is required.'); return; }
|
||||
if (!isEdit && !form.password.trim()) { setError('Password is required.'); return; }
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
first_name: form.name.trim(),
|
||||
email: form.email.trim(),
|
||||
phone: form.phone,
|
||||
role: form.role,
|
||||
...(form.pin ? { pin: String(form.pin) } : {})
|
||||
...(form.password ? { password: form.password } : {}),
|
||||
...(form.pin ? { pin: String(form.pin) } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await setPayload(COLLECTIONS.teamUsers, initial.id, payload);
|
||||
await updateUser(initial.id, payload);
|
||||
} else {
|
||||
await createPoint(COLLECTIONS.teamUsers, payload);
|
||||
await createUser(payload);
|
||||
}
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
@@ -59,31 +61,50 @@ export default function UserFormDialog({ open, mode, initial, onClose, onSaved }
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{isEdit ? 'Edit Team User' : 'Add Team User'}
|
||||
<IconButton onClick={onClose} size="small" disabled={saving}><CloseIcon /></IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<Grid container spacing={2} sx={{ mt: 0 }}>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Name *" value={form.name} onChange={set('name')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Email *" value={form.email} onChange={set('email')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Phone" value={form.phone} onChange={set('phone')} /></Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField select fullWidth size="small" label="Role" value={form.role} onChange={set('role')}>
|
||||
{withValue(ROLES, form.role).map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="PIN" value={form.pin} onChange={set('pin')} /></Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={saving} startIcon={saving ? <CircularProgress size={16} color="inherit" /> : null}>
|
||||
{isEdit ? 'Save Changes' : 'Create User'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
<Dialog isOpen={open} onOpenChange={(o) => { if (!o) onClose(); }} width={600} purpose="form">
|
||||
<Layout
|
||||
header={<DialogHeader title={isEdit ? 'Edit Team User' : 'Add Team User'} onOpenChange={(o) => { if (!o) onClose(); }} />}
|
||||
content={
|
||||
<LayoutContent>
|
||||
<FormLayout>
|
||||
{error && <Banner status="error" title={error} />}
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput label="Name" isRequired value={form.name} onChange={set('name')} />
|
||||
<TextInput label="Email" type="email" isRequired value={form.email} onChange={set('email')} />
|
||||
</FormLayout>
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput label="Phone" value={form.phone} onChange={set('phone')} />
|
||||
<Selector label="Role" options={selectorOptions(ROLES, form.role)} value={form.role} onChange={set('role')} />
|
||||
</FormLayout>
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput label="PIN" value={form.pin} onChange={set('pin')} />
|
||||
<TextInput
|
||||
label={isEdit ? 'New Password (optional)' : 'Password'}
|
||||
isRequired={!isEdit}
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={set('password')}
|
||||
/>
|
||||
</FormLayout>
|
||||
</FormLayout>
|
||||
</LayoutContent>
|
||||
}
|
||||
footer={
|
||||
<LayoutFooter hasDivider>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||
<Button label="Cancel" variant="ghost" onClick={onClose} isDisabled={saving} />
|
||||
<Button
|
||||
label={isEdit ? 'Save Changes' : 'Create User'}
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
isDisabled={saving}
|
||||
isLoading={saving}
|
||||
/>
|
||||
</div>
|
||||
</LayoutFooter>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,105 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, TextField,
|
||||
MenuItem, Box, Typography, Divider, Alert, CircularProgress, IconButton, useMediaQuery
|
||||
} from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { createPoint, setPayload, COLLECTIONS } from '@/utils/qdrant';
|
||||
import { MapPin } from 'lucide-react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||
import { FormLayout } from '@astryxdesign/core/FormLayout';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
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';
|
||||
|
||||
const BUSINESS_TYPES = ['retail', 'wholesale', 'manufacturer', 'distributor', 'services', 'ecommerce', 'other'];
|
||||
const STATUSES = ['newClient', 'contacted', 'onboarded', 'lost'];
|
||||
const FREQUENCIES = ['Daily', 'Weekly', 'Fortnightly', 'Monthly', 'Occasional'];
|
||||
const FREQUENCIES = ['Daily', 'Weekly', 'Bi-weekly', 'Monthly', 'On-demand'];
|
||||
const CONSENTS = ['basicOnly', 'full', 'none'];
|
||||
|
||||
const PROVIDERS = [
|
||||
'Blue Dart', 'Delhivery', 'DTDC', 'India Post / Speed Post', 'The Professional Couriers', 'XpressBees', 'Ecom Express', 'Shadowfax',
|
||||
'Safexpress', 'VRL Logistics', 'TCI (Transport Corporation of India)', 'Om Logistics', 'Best Roadways',
|
||||
'MSS (Mettur Super Services)', 'ABT Travels & Logistics', 'Navata Road Transport', 'KRS (Kerala Roadways)', 'Parveen Travels / Parveen Express', 'SRM Transports', 'KPN Travels & KPN Speed Parcel', 'SRS Travels',
|
||||
'Hindusthan Travels', 'City Travels', 'Essaar Travels', 'No. 1 Air Travels', 'A1 Travels', 'Krish Travels', 'Hebron Transports', 'Horma Travels', 'Vivegam Travels', 'PSS Transport', 'SRT (Renugambal Travels)', 'Thamarai Bus Transports', 'Rathimeena Travels', 'Ganesh Travels', 'John Kennedy Bus Service', 'Arun Travel', 'Saaji Meera Roadways', 'ARC Parcel Service', 'Chakra Travels & Parcel Service', 'MVA Parcel And Bus Service',
|
||||
'Shrinath Travels & Cargo', 'Hans Travels', 'Zingbus', 'Kalpana Travels / City Land Travels', 'Trackon Couriers', 'North India Transways', 'RSRTC Cargo', 'UPSRTC Cargo'
|
||||
];
|
||||
|
||||
const CITIES = [
|
||||
'Chennai', 'Coimbatore', 'Madurai', 'Tiruchirappalli', 'Salem', 'Tuticorin', 'Tirupur', 'Erode', 'Vellore', 'Tirunelveli', 'Thanjavur', 'Dindigul', 'Hosur', 'Nagercoil', 'Karur', 'Namakkal', 'Kanchipuram', 'Cuddalore', 'Thoothukudi',
|
||||
'Bengaluru', 'Mysuru', 'Mangaluru', 'Hubli', 'Belagavi', 'Kalaburagi', 'Davangere', 'Ballari',
|
||||
'Kochi', 'Thiruvananthapuram', 'Kozhikode', 'Kannur', 'Thrissur', 'Kollam',
|
||||
'Hyderabad', 'Warangal', 'Visakhapatnam', 'Vijayawada', 'Tirupati', 'Guntur', 'Rajahmundry', 'Nellore',
|
||||
'Mumbai', 'Pune', 'Nagpur', 'Nashik', 'Aurangabad', 'Kolhapur', 'Solapur',
|
||||
'New Delhi', 'Gurugram', 'Noida', 'Faridabad', 'Chandigarh', 'Amritsar', 'Ludhiana', 'Jalandhar',
|
||||
'Ahmedabad', 'Surat', 'Vadodara', 'Rajkot', 'Bhavnagar', 'Jamnagar',
|
||||
'Jaipur', 'Jodhpur', 'Udaipur', 'Kota', 'Bikaner',
|
||||
'Lucknow', 'Kanpur', 'Varanasi', 'Agra', 'Prayagraj', 'Gorakhpur', 'Bhopal', 'Indore', 'Gwalior', 'Jabalpur', 'Raipur',
|
||||
'Kolkata', 'Siliguri', 'Durgapur', 'Patna', 'Gaya', 'Ranchi', 'Jamshedpur', 'Bhubaneswar', 'Cuttack', 'Guwahati', 'Dibrugarh', 'Agartala', 'Imphal', 'Shillong', 'Aizawl', 'Dimapur',
|
||||
'Dehradun', 'Haridwar', 'Shimla', 'Srinagar', 'Jammu', 'Leh', 'Panaji', 'Puducherry', 'Port Blair'
|
||||
];
|
||||
|
||||
const EMPTY = {
|
||||
name: '', phone: '', city: '', businessState: '', businessType: 'retail', status: 'newClient',
|
||||
frequency: 'Daily', parcelVolume: 0, activeContracts: 0, provider: '', efficiency: '',
|
||||
name: '', email: '', password: '', phone: '', city: '', businessState: '',
|
||||
businessType: 'retail', status: 'newClient', frequency: 'Daily',
|
||||
parcelVolume: 0, activeContracts: 0, provider: '', efficiency: '',
|
||||
logisticsSegment: '', transitFrom: '', transitTo: '', neighbourhood: '',
|
||||
surveyAddress: '', surveyLat: '', surveyLng: '', dataConsent: 'basicOnly', notes: ''
|
||||
surveyAddress: '', surveyLat: '', surveyLng: '', dataConsent: 'full', notes: '', pincode: ''
|
||||
};
|
||||
|
||||
// Ensure a select always has its current value among the options.
|
||||
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 selectorOptions = (opts, v) => withValue(opts, v).map((o) => ({ value: o, label: formatOption(o) }));
|
||||
|
||||
export default function ClientFormDialog({ open, mode, initial, onClose, onSaved }) {
|
||||
const isEdit = mode === 'edit';
|
||||
const theme = useTheme();
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [gettingLocation, setGettingLocation] = useState(false);
|
||||
|
||||
const set = (k) => (val) => setForm((f) => ({ ...f, [k]: val }));
|
||||
|
||||
const handleGPS = () => {
|
||||
if (!navigator.geolocation) {
|
||||
alert("Geolocation is not supported by your browser");
|
||||
return;
|
||||
}
|
||||
setGettingLocation(true);
|
||||
navigator.geolocation.getCurrentPosition(async (pos) => {
|
||||
const lat = pos.coords.latitude;
|
||||
const lng = pos.coords.longitude;
|
||||
setForm((f) => ({ ...f, surveyLat: lat, surveyLng: lng }));
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lng}`);
|
||||
const data = await res.json();
|
||||
if (data && data.address) {
|
||||
const address = data.address;
|
||||
const city = address.city || address.town || address.village || address.county || '';
|
||||
const state = address.state || '';
|
||||
const pincode = address.postcode || '';
|
||||
const display = data.display_name || '';
|
||||
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
city: city || f.city,
|
||||
businessState: state || f.businessState,
|
||||
pincode: pincode || f.pincode,
|
||||
surveyAddress: display || f.surveyAddress
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Reverse geocoding failed", e);
|
||||
} finally {
|
||||
setGettingLocation(false);
|
||||
}
|
||||
}, (err) => {
|
||||
alert("Unable to retrieve your location");
|
||||
setGettingLocation(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -37,8 +108,6 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
|
||||
}
|
||||
}, [open, initial]);
|
||||
|
||||
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) { setError('Client name is required.'); return; }
|
||||
setSaving(true);
|
||||
@@ -48,7 +117,7 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
|
||||
const lng = form.surveyLng === '' ? undefined : Number(form.surveyLng);
|
||||
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
first_name: form.name.trim(),
|
||||
phone: form.phone,
|
||||
city: form.city,
|
||||
businessState: form.businessState,
|
||||
@@ -63,25 +132,22 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
|
||||
transitFrom: form.transitFrom,
|
||||
transitTo: form.transitTo,
|
||||
neighbourhood: form.neighbourhood,
|
||||
pincode: form.pincode,
|
||||
surveyAddress: form.surveyAddress,
|
||||
surveyZone: form.neighbourhood,
|
||||
dataConsent: form.dataConsent,
|
||||
notes: form.notes,
|
||||
lastUpdated: new Date().toISOString().slice(0, 10),
|
||||
...(lat != null ? { surveyLat: lat } : {}),
|
||||
...(lng != null ? { surveyLng: lng } : {}),
|
||||
...(lat != null && lng != null ? { surveyGeo: { lat, lon: lng } } : {})
|
||||
registration_source: 'web',
|
||||
...(form.email.trim() ? { email: form.email.trim() } : {}),
|
||||
...(lat != null ? { survey_lat: lat } : {}),
|
||||
...(lng != null ? { survey_long: lng } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await setPayload(COLLECTIONS.clients, initial.id, payload);
|
||||
await updateClient(initial.id, payload);
|
||||
} else {
|
||||
await createPoint(COLLECTIONS.clients, {
|
||||
...payload,
|
||||
clientId: `client_${Date.now()}`,
|
||||
surveySubmitted: false
|
||||
});
|
||||
await createClient({ ...payload, ...(form.password ? { password: form.password } : {}) });
|
||||
}
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
@@ -92,70 +158,108 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="md" fullWidth fullScreen={fullScreen}>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
{isEdit ? 'Edit Client' : 'Add Client'}
|
||||
<IconButton onClick={onClose} size="small" disabled={saving}><CloseIcon /></IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<Dialog isOpen={open} onOpenChange={(o) => { if (!o) onClose(); }} width={800} maxHeight="90dvh" purpose="form">
|
||||
<Layout
|
||||
header={<DialogHeader title={isEdit ? 'Edit Client' : 'Add Client'} onOpenChange={(o) => { if (!o) onClose(); }} />}
|
||||
content={
|
||||
<LayoutContent>
|
||||
<FormLayout>
|
||||
{error && <Banner status="error" title={error} />}
|
||||
|
||||
<Typography variant="overline" color="text.secondary">Business</Typography>
|
||||
<Grid container spacing={2} sx={{ mt: 0, mb: 1 }}>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Client Name *" value={form.name} onChange={set('name')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Phone" value={form.phone} onChange={set('phone')} /></Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField select fullWidth size="small" label="Business Type" value={form.businessType} onChange={set('businessType')}>
|
||||
{withValue(BUSINESS_TYPES, form.businessType).map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField select fullWidth size="small" label="Status" value={form.status} onChange={set('status')}>
|
||||
{withValue(STATUSES, form.status).map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField select fullWidth size="small" label="Order Frequency" value={form.frequency} onChange={set('frequency')}>
|
||||
{withValue(FREQUENCIES, form.frequency).map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)}
|
||||
</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}><TextField fullWidth size="small" label="Current Provider" value={form.provider} onChange={set('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="Business" />
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput label="Client Name" isRequired value={form.name} onChange={set('name')} />
|
||||
<TextInput label="Phone" value={form.phone} onChange={set('phone')} />
|
||||
</FormLayout>
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput label="Email" type="email" value={form.email} onChange={set('email')} />
|
||||
{!isEdit && <TextInput label="Password" type="password" value={form.password} onChange={set('password')} />}
|
||||
</FormLayout>
|
||||
<FormLayout direction="horizontal">
|
||||
<Selector label="Business Type" options={selectorOptions(BUSINESS_TYPES, form.businessType)} value={form.businessType} onChange={set('businessType')} />
|
||||
<Selector label="Status" options={selectorOptions(STATUSES, form.status)} value={form.status} onChange={set('status')} />
|
||||
</FormLayout>
|
||||
<FormLayout direction="horizontal">
|
||||
<Selector label="Order Frequency" options={selectorOptions(FREQUENCIES, form.frequency)} value={form.frequency} onChange={set('frequency')} />
|
||||
<NumberInput label="Parcel Volume" value={Number(form.parcelVolume) || 0} onChange={set('parcelVolume')} />
|
||||
<NumberInput label="Contracts" value={Number(form.activeContracts) || 0} onChange={set('activeContracts')} />
|
||||
</FormLayout>
|
||||
<FormLayout direction="horizontal">
|
||||
<div>
|
||||
<TextInput label="Current Provider" list="providers-list" value={form.provider || ''} onChange={set('provider')} />
|
||||
<datalist id="providers-list">
|
||||
{PROVIDERS.map((p) => <option key={p} value={p} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<TextInput label="Efficiency" value={form.efficiency} onChange={set('efficiency')} />
|
||||
</FormLayout>
|
||||
<TextInput label="Logistics Segment" placeholder="First Mile, Last Mile" value={form.logisticsSegment} onChange={set('logisticsSegment')} />
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<Typography variant="overline" color="text.secondary">Location & Transit</Typography>
|
||||
<Grid container spacing={2} sx={{ mt: 0, mb: 1 }}>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="City" value={form.city} onChange={set('city')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="State" value={form.businessState} onChange={set('businessState')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Neighbourhood / Zone" value={form.neighbourhood} onChange={set('neighbourhood')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Transit From" value={form.transitFrom} onChange={set('transitFrom')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Transit To" value={form.transitTo} onChange={set('transitTo')} /></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}><TextField fullWidth size="small" label="Survey Address" value={form.surveyAddress} onChange={set('surveyAddress')} multiline minRows={2} /></Grid>
|
||||
</Grid>
|
||||
<Divider label="Location & Transit" />
|
||||
<FormLayout direction="horizontal">
|
||||
<div>
|
||||
<TextInput label="City" list="cities-list" value={form.city || ''} onChange={set('city')} />
|
||||
<datalist id="cities-list">
|
||||
{CITIES.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<TextInput label="State" value={form.businessState} onChange={set('businessState')} />
|
||||
</FormLayout>
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput label="Pincode" value={form.pincode} onChange={set('pincode')} />
|
||||
<TextInput label="Neighbourhood / Zone" value={form.neighbourhood} onChange={set('neighbourhood')} />
|
||||
</FormLayout>
|
||||
<FormLayout direction="horizontal">
|
||||
<div>
|
||||
<TextInput label="Transit From" list="cities-list-from" value={form.transitFrom || ''} onChange={set('transitFrom')} />
|
||||
<datalist id="cities-list-from">
|
||||
{CITIES.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<TextInput label="Transit To" list="cities-list-to" value={form.transitTo || ''} onChange={set('transitTo')} />
|
||||
<datalist id="cities-list-to">
|
||||
{CITIES.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
</FormLayout>
|
||||
<FormLayout direction="horizontal">
|
||||
<NumberInput label="Latitude" value={form.surveyLat === '' ? undefined : Number(form.surveyLat)} onChange={set('surveyLat')} />
|
||||
<NumberInput label="Longitude" value={form.surveyLng === '' ? undefined : Number(form.surveyLng)} onChange={set('surveyLng')} />
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||||
<Button
|
||||
label={gettingLocation ? "Getting GPS..." : "Get GPS Location"}
|
||||
variant="secondary"
|
||||
icon={gettingLocation ? undefined : <MapPin size={16} />}
|
||||
onClick={handleGPS}
|
||||
isDisabled={gettingLocation}
|
||||
style={{ width: '100%', height: '40px' }}
|
||||
/>
|
||||
</div>
|
||||
</FormLayout>
|
||||
<TextArea label="Survey Address" rows={2} value={form.surveyAddress} onChange={set('surveyAddress')} />
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
<Typography variant="overline" color="text.secondary">Other</Typography>
|
||||
<Grid container spacing={2} sx={{ mt: 0 }}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<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}>{o}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12}><TextField fullWidth size="small" label="Notes" value={form.notes} onChange={set('notes')} multiline minRows={2} /></Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={saving} startIcon={saving ? <CircularProgress size={16} color="inherit" /> : null}>
|
||||
{isEdit ? 'Save Changes' : 'Create Client'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
<Divider label="Other" />
|
||||
<Selector label="Data Consent" options={selectorOptions(CONSENTS, form.dataConsent)} value={form.dataConsent} onChange={set('dataConsent')} />
|
||||
<TextArea label="Notes" rows={2} value={form.notes} onChange={set('notes')} />
|
||||
</FormLayout>
|
||||
</LayoutContent>
|
||||
}
|
||||
footer={
|
||||
<LayoutFooter hasDivider>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
||||
<Button label="Cancel" variant="ghost" onClick={onClose} isDisabled={saving} />
|
||||
<Button
|
||||
label={isEdit ? 'Save Changes' : 'Create Client'}
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
isDisabled={saving}
|
||||
isLoading={saving}
|
||||
/>
|
||||
</div>
|
||||
</LayoutFooter>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,121 +0,0 @@
|
||||
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 } }
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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;
|
||||
@@ -1,102 +0,0 @@
|
||||
// ==============================|| 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;
|
||||
@@ -1,14 +0,0 @@
|
||||
// ==============================|| 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;
|
||||
@@ -1,25 +0,0 @@
|
||||
// ==============================|| 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;
|
||||
125
src/utils/apiClient.js
Normal file
125
src/utils/apiClient.js
Normal file
@@ -0,0 +1,125 @@
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || 'https://api.doormile.com/api/v1';
|
||||
|
||||
const getHeaders = () => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
};
|
||||
|
||||
const parseJson = (res) => res.status === 204 ? {} : res.json().catch(() => ({}));
|
||||
|
||||
const extractArray = (json) => (Array.isArray(json) ? json : (Array.isArray(json?.data) ? json.data : []));
|
||||
|
||||
export async function fetchClients() {
|
||||
const res = await fetch(`${API_BASE}/crm/clients?limit=1000`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error('Failed to fetch clients');
|
||||
const json = await res.json();
|
||||
return extractArray(json);
|
||||
}
|
||||
|
||||
export async function loginAdmin(email, password) {
|
||||
const res = await fetch(`${API_BASE}/admin/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Failed to login');
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createClient(payload) {
|
||||
const res = await fetch(`${API_BASE}/crm/clients`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || err.message || 'Failed to create client');
|
||||
}
|
||||
return parseJson(res);
|
||||
}
|
||||
|
||||
export async function updateClient(id, payload) {
|
||||
const res = await fetch(`${API_BASE}/crm/clients/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || err.message || 'Failed to update client');
|
||||
}
|
||||
return parseJson(res);
|
||||
}
|
||||
|
||||
export async function deleteClient(id) {
|
||||
const res = await fetch(`${API_BASE}/crm/clients/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete client');
|
||||
return parseJson(res);
|
||||
}
|
||||
|
||||
export async function fetchUsers() {
|
||||
const res = await fetch(`${API_BASE}/admin/users?limit=1000`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error('Failed to fetch users');
|
||||
const json = await res.json();
|
||||
return extractArray(json);
|
||||
}
|
||||
|
||||
export async function createUser(payload) {
|
||||
const res = await fetch(`${API_BASE}/admin/users`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || err.message || 'Failed to create user');
|
||||
}
|
||||
return parseJson(res);
|
||||
}
|
||||
|
||||
export async function updateUser(id, payload) {
|
||||
const res = await fetch(`${API_BASE}/admin/users/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || err.message || 'Failed to update user');
|
||||
}
|
||||
return parseJson(res);
|
||||
}
|
||||
|
||||
export async function deleteUser(id) {
|
||||
const res = await fetch(`${API_BASE}/admin/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete user');
|
||||
return parseJson(res);
|
||||
}
|
||||
|
||||
export async function fetchDashboard() {
|
||||
const res = await fetch(`${API_BASE}/admin/dashboard`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error('Failed to fetch dashboard');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteCompetitorBranch(id) {
|
||||
const res = await fetch(`${API_BASE}/admin/competitor-branches/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete survey record');
|
||||
return parseJson(res);
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
// ==============================|| FORMAT HELPERS ||============================== //
|
||||
export const titleCase = (s) =>
|
||||
String(s || '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
.trim();
|
||||
|
||||
export const inr = (n) =>
|
||||
'₹' + Number(n || 0).toLocaleString('en-IN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
|
||||
51
src/utils/mappers.js
Normal file
51
src/utils/mappers.js
Normal file
@@ -0,0 +1,51 @@
|
||||
export const generateLogicalId = (id) => {
|
||||
const str = String(id).replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||
return 'CLI-' + str.substring(0, 6).padStart(6, '0');
|
||||
};
|
||||
|
||||
export function toClient(raw) {
|
||||
const p = raw.payload ?? raw;
|
||||
const id = p.id ?? raw.id;
|
||||
const logicalId = generateLogicalId(id);
|
||||
return {
|
||||
id,
|
||||
logicalId,
|
||||
clientId: logicalId,
|
||||
name: p.first_name ? `${p.first_name} ${p.last_name || ''}`.trim() : (p.name || '—'),
|
||||
email: p.email || '',
|
||||
phone: p.phone || '',
|
||||
city: p.city || '',
|
||||
businessState: p.businessState || '',
|
||||
businessType: p.businessType || '',
|
||||
status: p.status || 'unknown',
|
||||
parcelVolume: Number(p.parcelVolume) || 0,
|
||||
activeContracts: Number(p.activeContracts) || 0,
|
||||
frequency: p.frequency || '',
|
||||
provider: p.provider || '',
|
||||
efficiency: p.efficiency || '',
|
||||
logisticsSegment: p.logisticsSegment || '',
|
||||
transitFrom: p.transitFrom || '',
|
||||
transitTo: p.transitTo || '',
|
||||
neighbourhood: p.neighbourhood || p.surveyZone || '',
|
||||
surveyAddress: p.surveyAddress || p.address || '',
|
||||
surveyLat: p.survey_lat ?? p.surveyLat ?? '',
|
||||
surveyLng: p.survey_long ?? p.surveyLng ?? '',
|
||||
dataConsent: p.dataConsent || '',
|
||||
lastUpdated: p.lastUpdated || '',
|
||||
pincode: p.pincode || p.postal_code || '',
|
||||
notes: p.notes || ''
|
||||
};
|
||||
}
|
||||
|
||||
export function toUser(raw) {
|
||||
const p = raw.payload ?? raw;
|
||||
const id = p.id ?? raw.id ?? p.uid ?? raw.uid ?? p.email;
|
||||
return {
|
||||
id,
|
||||
name: p.first_name ? `${p.first_name} ${p.last_name || ''}`.trim() : (p.name || '—'),
|
||||
email: p.email || '',
|
||||
phone: p.phone || '',
|
||||
role: p.role || 'unknown',
|
||||
pin: p.pin || ''
|
||||
};
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
// ==============================|| QDRANT DATA LAYER ||============================== //
|
||||
// Real connection to the Doormile Qdrant cluster (read + write).
|
||||
//
|
||||
// Requests go through the Vite dev proxy at `/qdrant` (see vite.config.js), which
|
||||
// injects the api-key server-side so it never ships in the browser bundle and CORS
|
||||
// is avoided. For a production build, point VITE_QDRANT_BASE at your own proxy.
|
||||
|
||||
const BASE = import.meta.env.VITE_QDRANT_BASE || '/qdrant';
|
||||
|
||||
export const COLLECTIONS = {
|
||||
clients: 'doormile_clients',
|
||||
teamUsers: 'doormile_auth'
|
||||
};
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = res.statusText;
|
||||
try {
|
||||
const body = await res.json();
|
||||
detail = body?.status?.error || body?.status || detail;
|
||||
} catch { /* ignore non-json error bodies */ }
|
||||
throw new Error(`Qdrant ${res.status}: ${detail}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll every point of a collection (follows next_page_offset until exhausted).
|
||||
* Returns an array of { id, payload } objects with the raw Qdrant payload.
|
||||
*/
|
||||
export async function fetchPoints(collection, { pageSize = 250, withVector = false } = {}) {
|
||||
const all = [];
|
||||
let offset = null;
|
||||
|
||||
for (let guard = 0; guard < 1000; guard += 1) {
|
||||
const data = await request(`/collections/${collection}/points/scroll`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
limit: pageSize,
|
||||
with_payload: true,
|
||||
with_vector: withVector,
|
||||
...(offset != null ? { offset } : {})
|
||||
})
|
||||
});
|
||||
|
||||
const points = data?.result?.points || [];
|
||||
all.push(...points);
|
||||
|
||||
offset = data?.result?.next_page_offset ?? null;
|
||||
if (offset == null || points.length === 0) break;
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
// Cache vector sizes per collection so we don't re-fetch the config on every write.
|
||||
const _vectorSizeCache = {};
|
||||
|
||||
export async function getVectorSize(collection) {
|
||||
if (_vectorSizeCache[collection] != null) return _vectorSizeCache[collection];
|
||||
const data = await request(`/collections/${collection}`);
|
||||
const vectors = data?.result?.config?.params?.vectors;
|
||||
// Single unnamed vector → { size, distance }. Default to 1 if absent.
|
||||
const size = typeof vectors?.size === 'number' ? vectors.size : 1;
|
||||
_vectorSizeCache[collection] = size;
|
||||
return size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the payload of an existing point (merges the given keys; vectors untouched).
|
||||
*/
|
||||
export async function setPayload(collection, id, payload) {
|
||||
return request(`/collections/${collection}/points/payload?wait=true`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payload, points: [id] })
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a brand-new point. The collection requires a vector of a fixed size, but
|
||||
* this CRM doesn't do semantic search, so we store a zero-vector of the right length.
|
||||
* Returns the generated point id.
|
||||
*/
|
||||
export async function createPoint(collection, payload) {
|
||||
const size = await getVectorSize(collection);
|
||||
const id = (crypto?.randomUUID && crypto.randomUUID()) || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const vector = new Array(size).fill(0);
|
||||
|
||||
await request(`/collections/${collection}/points?wait=true`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ points: [{ id, vector, payload }] })
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a point by id.
|
||||
*/
|
||||
export async function deletePoint(collection, id) {
|
||||
return request(`/collections/${collection}/points/delete?wait=true`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ points: [id] })
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true,
|
||||
host: true,
|
||||
// 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.
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user