6 Commits

47 changed files with 7175 additions and 4683 deletions

View File

@@ -1,54 +1,50 @@
# Doormile CRM # Doormile Console — Admin
A modern, corporate customer relationship management system for **Doormile**, a last-mile logistics and courier delivery operation. Built with **React 18 + Vite + Material-UI v5**, themed around the Doormile brand red `#C01227`. A modern, corporate logistics console for **Doormile**, a last-mile / courier delivery operation. Built with **React 18 + Vite + Material-UI v5**, themed around the Doormile brand red `#C01227`.
## Quick Start ## Quick start
```bash ```bash
# Install dependencies
npm install npm install
npm run dev # http://localhost:3000
# Run the development server (default port: 5173 or similar) npm run build # production build → dist/
npm run dev npm run preview # preview the production build
# Build the project for production
npm run build
# Preview the production build locally
npm run preview
``` ```
## Application Structure & Pages ## What's inside
Doormile CRM features a clean, responsive layout consisting of a **collapsible sidebar** and a **header panel**, wrapping the following key modules: A clean, data-dense but breathable corporate shell — fixed **red top header** (search, notifications, messages, profile) + **collapsible red sidebar** (260px ↔ 78px icon rail) + light `#FAFAFB` content area — wrapping **27 screens**:
| Route Path | Page Component | Description | | Area | Screens |
| --- | --- | --- | | --- | --- |
| `/dashboard` | `Dashboard.jsx` | Overview of registered clients, active contracts, total parcel volume, status donuts, and quick-view charts. | | Overview | Dashboard |
| `/tenants` | `Tenants.jsx` | Management view for clients, including expandable details (parcel volume, logistics segments, transit routes, and coordinates map link). | | Orders | Orders list · Order Details (tracking + timeline) · Create Order · Create Multiple Orders · Assign Orders |
| `/survey` | `Survey.jsx` | Provider survey records mapping competitors, branch locations, and rate-per-kg answers in an aligned grid layout. | | Deliveries | Deliveries (expandable products, status tabs) |
| `/pricing` | `Pricing.jsx` | Pricing matrix slabs and logistics carrier rates management with search and active-provider selectors. | | Network | Tenants · Create Client · Customers · Create Customer · Pricing · Riders · Create Rider · Edit Rider |
| `/bookings` | `Bookings.jsx` | Courier bookings creation and management dialogs, along with pricing checks. | | Reports | Order Summary · Order Details · Riders Summary · Riders Logs (live map) |
| `/app-users` | `TeamUsers.jsx` | Team member list, user roles configuration, and initial permission setup. | | Finance | Invoices · Invoice Preview (printable A4) · Requests |
| `/settings` | `Settings.jsx` | Preferences dashboard covering organization profiles, system notification switches, and security/password updates. | | Account | User Profile |
| Auth & states | Login · 404 · 500 · Under Construction · Coming Soon |
## Design System ## Design system
- **Brand red** `#C01227` (`lighter #F8E0E3` and `darker #7E0B17`) representing the primary action theme color. - **Brand red** `#C01227` (`lighter #F8E0E3darker #7E0B17`), white-on-red header/sidebar.
- **Status palette** — success `#00A854`, warning `#FFBF00`, info `#00A2AE`, error `#F04134`. - **Status palette** — success `#00A854`, warning `#FFBF00`, info `#00A2AE`, error `#F04134`.
- **Public Sans** typography scale, clean border-radii (`8px` to `16px`), soft shadows, and subtle responsive layouts (flex-based grid column alignment). - **Public Sans** type scale, 610px radii, soft `0 1px 4px` card shadows, and a signature **red glow** on primary CTAs.
- Theme configuration resides in `src/theme/`. - All tuning lives in `src/theme/` (`palette.js`, `typography.js`, `shadows.js`, `componentsOverride.js`).
## Project Folders ## Project structure
``` ```
src/ src/
components/ Reusable UI widgets (PageHeader, StatCard, StatusChip, EmptyState) theme/ Doormile red theme + MUI component overrides
layout/ MainLayout (header + collapsible sidebar) & MinimalLayout layout/ MainLayout (red header + collapsible sidebar) · MinimalLayout
menu/ Sidebar navigation mappings (`navItems.jsx`) menu/ sidebar nav config
pages/ CRM modules and page components (dashboard, tenants, survey, pricing, bookings, team, settings) components/ PageHeader, StatCard, StatusChip, MainCard, EmptyState,
theme/ Doormile custom theme configurations and component style overrides UserAvatar, MapPlaceholder, Logo, charts/ (Area, Donut)
utils/ API fetching wrapper client data/mock.js static demo data powering every screen
App.jsx App router and route definitions pages/ all 27 screens (lazy-loaded)
main.jsx Application entry point App.jsx router · main.jsx app entry
``` ```
Charts and maps are dependency-free SVG placeholders (`components/charts`, `MapPlaceholder`) — swap in Recharts / Leaflet / Google Maps when wiring real data. All data is mocked in `src/data/mock.js`; there is no backend.

View File

@@ -9,7 +9,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <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" rel="stylesheet"
/> />
<title>Doormile CRM</title> <title>Doormile CRM</title>

1827
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,15 +10,12 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.4", "@astryxdesign/cli": "^0.1.2",
"@emotion/styled": "^11.11.5", "@astryxdesign/core": "^0.1.2",
"@mui/icons-material": "^5.15.20", "@astryxdesign/theme-neutral": "^0.1.2",
"@mui/lab": "^5.0.0-alpha.170",
"@mui/material": "^5.15.20",
"@mui/x-date-pickers": "^6.20.2",
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"react": "^18.3.1", "react": "^19.2.7",
"react-dom": "^18.3.1", "react-dom": "^19.2.7",
"react-router-dom": "^6.23.1" "react-router-dom": "^6.23.1"
}, },
"devDependencies": { "devDependencies": {

BIN
public/navbarLogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -1,6 +1,5 @@
import { Suspense, lazy } from 'react'; import { Suspense, lazy } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import { Box, CircularProgress } from '@mui/material';
import MainLayout from '@/layout/MainLayout'; import MainLayout from '@/layout/MainLayout';
import MinimalLayout from '@/layout/MinimalLayout'; import MinimalLayout from '@/layout/MinimalLayout';
@@ -12,9 +11,10 @@ const load = (factory) => {
return ( return (
<Suspense <Suspense
fallback={ fallback={
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
<CircularProgress color="primary" /> <div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #0A1317', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
</Box> <style>{`@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`}</style>
</div>
} }
> >
<C /> <C />
@@ -29,7 +29,7 @@ export default function App() {
<Route element={<AuthGuard><MainLayout /></AuthGuard>}> <Route element={<AuthGuard><MainLayout /></AuthGuard>}>
<Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} /> <Route path="/dashboard" element={load(() => import('@/pages/Dashboard'))} />
<Route path="/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="/survey" element={load(() => import('@/pages/survey/Survey.jsx'))} />
@@ -37,7 +37,7 @@ export default function App() {
<Route path="/bookings" element={load(() => import('@/pages/bookings/Bookings'))} /> <Route path="/bookings" element={load(() => import('@/pages/bookings/Bookings'))} />
<Route path="/app-users" element={load(() => import('@/pages/team/TeamUsers'))} /> <Route path="/team-users" element={load(() => import('@/pages/team/TeamUsers'))} />
<Route path="/settings" element={load(() => import('@/pages/Settings'))} /> <Route path="/settings" element={load(() => import('@/pages/Settings'))} />
</Route> </Route>

View 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 (095) 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
View 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&apos;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>
);
}

View 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 &amp; 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&apos;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&apos;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 &amp; 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&apos;t my info shown here?
</Text>
<Text
type="supporting"
color="secondary"
display="block">
We&apos;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&apos;ll need to get verified again the next time
you bookor 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&apos;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&apos;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
View 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>
}
/>
);
}

View File

@@ -1,33 +1,13 @@
import { Box, Typography } from '@mui/material'; import { EmptyState as AstryxEmptyState } from '@astryxdesign/core/EmptyState';
import InboxOutlinedIcon from '@mui/icons-material/InboxOutlined';
// ==============================|| EMPTY STATE ||============================== // // ==============================|| 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 ( return (
<Box sx={{ textAlign: 'center', py: 6, px: 2, ...sx }}> <AstryxEmptyState
<Box title={title}
sx={{ description={caption}
width: 72, icon={Icon ? <Icon size={36} style={{ color: '#94a3b8' }} /> : undefined}
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>
); );
} }

View File

@@ -1,44 +1,67 @@
import { Box, Typography } from '@mui/material';
// ==============================|| DOORMILE WORDMARK LOGO ||============================== // // ==============================|| DOORMILE WORDMARK LOGO ||============================== //
// Uses the brand wordmark asset (white PNG). `onDark` shows it as-is on dark/red // Uses the brand wordmark asset (white PNG). `onDark` shows it as-is on dark/red
// surfaces; on light surfaces it is recoloured to near-black. `compact` (e.g. the // surfaces; on light surfaces it is recoloured to near-black. `compact` (e.g. the
// 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 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) { if (compact) {
const mark = onDark ? '#FFFFFF' : '#C01227';
const markText = onDark ? '#C01227' : '#FFFFFF';
return ( return (
<Box sx={{ display: 'flex', alignItems: 'center', ...sx }}> <div
<Box style={{
sx={{
width: 34,
height: 34,
borderRadius: 2,
bgcolor: mark,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', gap: 9,
flexShrink: 0, // Nudges the mark so its optical centre lines up with the collapsed
boxShadow: onDark ? 'none' : '0 4px 10px rgba(192, 18, 39,0.30)' // 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
}} }}
> >
<Typography sx={{ color: markText, fontWeight: 800, fontSize: '1.25rem', lineHeight: 1 }}>D</Typography> <img
</Box> src={NAVBAR_MARK_SRC}
</Box> // 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,
// 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'
}}
>
Doormile
</span>
</div>
); );
} }
return ( return (
<Box sx={{ display: 'flex', alignItems: 'center', ...sx }}> <div style={{ display: 'flex', alignItems: 'center', ...style }}>
<Box <img
component="img"
src={LOGO_SRC} src={LOGO_SRC}
alt="Doormile" alt="Doormile"
sx={{ style={{
height, height,
width: 'auto', width: 'auto',
display: 'block', display: 'block',
@@ -46,6 +69,6 @@ export default function Logo({ onDark = false, compact = false, height = 26, sx
filter: onDark ? 'none' : 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)' filter: onDark ? 'none' : 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)'
}} }}
/> />
</Box> </div>
); );
} }

View File

@@ -1,42 +1,24 @@
import { Box, Typography, Breadcrumbs, Link, Stack } from '@mui/material'; // ==============================|| PAGE HEADER (title + actions) ||============================== //
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { Link as RouterLink } from 'react-router-dom';
// ==============================|| PAGE HEADER (title + breadcrumb + actions) ||============================== // export default function PageHeader({ title, action }) {
export default function PageHeader({ title, breadcrumbs = [], action }) {
return ( return (
<Stack <div
direction={{ xs: 'column', sm: 'row' }} style={{
justifyContent="space-between" display: 'flex',
alignItems={{ xs: 'flex-start', sm: 'center' }} flexDirection: 'row',
spacing={1.5} justifyContent: 'space-between',
sx={{ mb: 3 }} alignItems: 'center',
flexWrap: 'wrap',
gap: '12px',
marginBottom: '24px'
}}
> >
<Box> <div style={{ minWidth: 0 }}>
<Typography variant="h3" sx={{ fontWeight: 700, color: 'grey.800' }}> <h1 style={{ margin: 0, fontSize: '1.75rem', fontWeight: 700, color: '#1e293b' }}>
{title} {title}
</Typography> </h1>
{breadcrumbs.length > 0 && ( </div>
<Breadcrumbs separator={<NavigateNextIcon fontSize="small" />} sx={{ mt: 0.5 }}> {action && <div style={{ flexShrink: 1, minWidth: 0 }}>{action}</div>}
<Link component={RouterLink} to="/dashboard" underline="hover" color="text.secondary" variant="caption"> </div>
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>
); );
} }

View File

@@ -1,83 +1,99 @@
import { Card, CardContent, Box, Typography, Avatar, Stack } from '@mui/material'; import { Card } from '@astryxdesign/core/Card';
import ArrowUpwardRoundedIcon from '@mui/icons-material/ArrowUpwardRounded';
import ArrowDownwardRoundedIcon from '@mui/icons-material/ArrowDownwardRounded';
// ==============================|| STAT / KPI CARD ||============================== // // ==============================|| STAT / KPI CARD ||============================== //
export default function StatCard({ title, value, icon: Icon, color = 'primary', trend, caption, 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 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 ( return (
<Card <Card
sx={{ style={{
height: '100%', height: '100%',
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
borderRadius: 3, borderRadius: isCompact ? '12px' : '16px',
boxShadow: '0 8px 24px rgba(0,0,0,0.03)', padding: isCompact ? '16px' : '24px',
border: '1px solid rgba(0,0,0,0.04)', 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)', 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)' }, cursor: 'pointer',
background: (theme) => `linear-gradient(145deg, ${theme.palette.background.paper} 0%, ${theme.palette[color].lighter}15 100%)` boxSizing: 'border-box'
}} }}
className="stat-card"
> >
{Icon && ( {Icon && (
<Box sx={{ position: 'absolute', right: -15, bottom: -15, opacity: 0.04, transform: 'rotate(-15deg)', pointerEvents: 'none' }}> <div style={{ position: 'absolute', right: '-12px', bottom: '-12px', opacity: 0.04, transform: 'rotate(-15deg)', pointerEvents: 'none', color: themeColor.main }}>
<Icon sx={{ fontSize: 110 }} /> <Icon size={isCompact ? 64 : 96} />
</Box> </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 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: isCompact ? '10px' : '16px', position: 'relative', zIndex: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.secondary' }}> <span style={{ fontWeight: 700, fontSize: '0.75rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{title} {title}
</Typography> </span>
{Icon && ( {Icon && (
<Box <div
sx={{ style={{
width: 38, width: isCompact ? '28px' : '36px',
height: 38, height: isCompact ? '28px' : '36px',
borderRadius: 2, borderRadius: '8px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
bgcolor: `${color}.lighter`, backgroundColor: 'rgba(10, 19, 23, 0.08)',
color: `${color}.main`, color: '#0A1317'
boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.05)'
}} }}
> >
<Icon fontSize="small" /> <Icon size={isCompact ? 14 : 18} />
</Box> </div>
)} )}
</Stack> </div>
<Box sx={{ flexGrow: 1, position: 'relative', zIndex: 2 }}> <div style={{ flexGrow: 1, position: 'relative', zIndex: 2, marginBottom: (trendUp !== null || caption) ? (isCompact ? '10px' : '16px') : '0' }}>
<Typography variant="h4" sx={{ fontWeight: 800, color: 'grey.900', letterSpacing: '-0.5px', lineHeight: 1 }}> <span style={{ fontWeight: 800, fontSize: isCompact ? '1.5rem' : '2rem', color: '#1e293b', lineHeight: 1, display: 'block' }}>
{value} {value}
</Typography> </span>
</Box> </div>
{(trendUp !== null || caption) && ( {(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 }}> <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '8px', paddingTop: isCompact ? '8px' : '12px', borderTop: '1px dashed #e2e8f0', position: 'relative', zIndex: 2 }}>
{trendUp !== null && ( {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 }}> <div
{trendUp ? ( style={{
<ArrowUpwardRoundedIcon sx={{ fontSize: 14, color: 'success.dark' }} /> display: 'flex',
) : ( alignItems: 'center',
<ArrowDownwardRoundedIcon sx={{ fontSize: 14, color: 'error.dark' }} /> gap: '2px',
)} backgroundColor: trendUp ? '#E3F6EC' : '#FEEAE9',
<Typography variant="caption" sx={{ fontWeight: 700, color: trendUp ? 'success.dark' : 'error.dark' }}> padding: '2px 6px',
{Math.abs(trend)}% borderRadius: '4px'
</Typography> }}
</Stack> >
<span style={{ fontSize: '11px', fontWeight: 700, color: trendUp ? '#00773B' : '#A82216' }}>
{trendUp ? '↑' : '↓'} {Math.abs(trend)}%
</span>
</div>
)} )}
{caption && ( {caption && (
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}> <span style={{ fontSize: '0.75rem', color: '#64748b', fontWeight: 600 }}>
{caption} {caption}
</Typography> </span>
)} )}
</Stack> </div>
)} )}
</CardContent>
</Card> </Card>
); );
} }

View File

@@ -1,65 +1,56 @@
import { Chip } from '@mui/material'; import { Badge } from '@astryxdesign/core/Badge';
// ==============================|| STATUS CHIP ||============================== // // ==============================|| STATUS CHIP ||============================== //
// Soft-filled status chips used across orders, deliveries, riders, invoices. // Soft-filled status chips used across orders, deliveries, riders, invoices.
const MAP = { const MAP = {
// orders / deliveries // orders / deliveries
pending: { color: 'warning', label: 'Pending' }, pending: { variant: 'warning', label: 'Pending' },
created: { color: 'info', label: 'Created' }, created: { variant: 'info', label: 'Created' },
assigned: { color: 'info', label: 'Assigned' }, assigned: { variant: 'info', label: 'Assigned' },
accepted: { color: 'info', label: 'Accepted' }, accepted: { variant: 'info', label: 'Accepted' },
arrived: { color: 'info', label: 'Arrived' }, arrived: { variant: 'info', label: 'Arrived' },
picked: { color: 'primary', label: 'Picked' }, picked: { variant: 'red', label: 'Picked' },
'in-transit': { color: 'info', label: 'In Transit' }, 'in-transit': { variant: 'info', label: 'In Transit' },
active: { color: 'primary', label: 'Active' }, active: { variant: 'red', label: 'Active' },
delivered: { color: 'success', label: 'Delivered' }, delivered: { variant: 'success', label: 'Delivered' },
completed: { color: 'success', label: 'Completed' }, completed: { variant: 'success', label: 'Completed' },
skipped: { color: 'warning', label: 'Skipped' }, skipped: { variant: 'warning', label: 'Skipped' },
failed: { color: 'error', label: 'Failed' }, failed: { variant: 'error', label: 'Failed' },
cancelled: { color: 'error', label: 'Cancelled' }, cancelled: { variant: 'error', label: 'Cancelled' },
'miler-assigned': { variant: 'info', label: 'Miler Assigned' },
'pending-pickup': { variant: 'warning', label: 'Pending Pickup' },
// clients (doormile_clients) // clients (doormile_clients)
newclient: { color: 'info', label: 'New Client' }, newclient: { variant: 'info', label: 'New Client' },
contacted: { color: 'warning', label: 'Contacted' }, contacted: { variant: 'warning', label: 'Contacted' },
onboarded: { color: 'success', label: 'Onboarded' }, onboarded: { variant: 'success', label: 'Onboarded' },
lost: { color: 'error', label: 'Lost' }, lost: { variant: 'error', label: 'Lost' },
// team users (doormile_auth) // team users (doormile_auth)
admin: { color: 'primary', label: 'Admin' }, admin: { variant: 'red', label: 'Admin' },
rep: { color: 'info', label: 'Rep' }, rep: { variant: 'info', label: 'Rep' },
manager: { color: 'success', label: 'Manager' }, manager: { variant: 'success', label: 'Manager' },
support: { color: 'warning', label: 'Support' }, support: { variant: 'warning', label: 'Support' },
// riders / tenants // riders / tenants
online: { color: 'success', label: 'Online' }, online: { variant: 'success', label: 'Online' },
offline: { color: 'default', label: 'Offline' }, offline: { variant: 'neutral', label: 'Offline' },
'on-delivery': { color: 'info', label: 'On Delivery' }, 'on-delivery': { variant: 'info', label: 'On Delivery' },
inactive: { color: 'default', label: 'Inactive' }, inactive: { variant: 'neutral', label: 'Inactive' },
// invoices // invoices
paid: { color: 'success', label: 'Paid' }, paid: { variant: 'success', label: 'Paid' },
open: { color: 'info', label: 'Open' }, open: { variant: 'info', label: 'Open' },
overdue: { color: 'error', label: 'Overdue' }, overdue: { variant: 'error', label: 'Overdue' },
prepaid: { color: 'success', label: 'Prepaid' }, prepaid: { variant: 'success', label: 'Prepaid' },
cod: { color: 'warning', label: 'COD' } cod: { variant: 'warning', label: 'COD' }
}; };
const TONE = { export default function StatusChip({ status, label }) {
success: { bg: '#E3F6EC', fg: '#00773B' },
warning: { bg: '#FFF7E0', fg: '#8A6500' },
info: { bg: '#E0F7F8', fg: '#00727B' },
error: { bg: '#FEEAE9', fg: '#A82216' },
primary: { bg: '#F8E0E3', fg: '#9E0E20' },
default: { bg: '#F0F0F0', fg: '#595959' }
};
export default function StatusChip({ status, size = 'small', label, sx }) {
const key = String(status || '').toLowerCase().replace(/\s+/g, '-'); const key = String(status || '').toLowerCase().replace(/\s+/g, '-');
const humanized = String(status || '').replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); const humanized = String(status || '').replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const cfg = MAP[key] || { color: 'default', label: humanized || status }; const cfg = MAP[key] || { variant: 'neutral', label: humanized || status };
const tone = TONE[cfg.color] || TONE.default;
return ( return (
<Chip <Badge
size={size} variant={cfg.variant}
label={label || cfg.label} label={label || cfg.label}
sx={{ bgcolor: tone.bg, color: tone.fg, border: 'none', ...sx }}
/> />
); );
} }

View File

@@ -1,4 +1,4 @@
import { Stack, Box } from '@mui/material'; import { Badge } from '@astryxdesign/core/Badge';
// ==============================|| TAB LABEL WITH INLINE COUNT PILL ||============================== // // ==============================|| TAB LABEL WITH INLINE COUNT PILL ||============================== //
// Renders a tab label with the count laid out inline (not an overlapping Badge), // Renders a tab label with the count laid out inline (not an overlapping Badge),
@@ -6,25 +6,9 @@ import { Stack, Box } from '@mui/material';
export default function TabLabelCount({ label, count, active = false }) { export default function TabLabelCount({ label, count, active = false }) {
return ( return (
<Stack direction="row" alignItems="center" spacing={1}> <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '8px' }}>
<span>{label}</span> <span>{label}</span>
<Box <Badge variant={active ? 'red' : 'neutral'} label={count} />
component="span" </div>
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>
); );
} }

View 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>
);
}

View File

@@ -1,12 +1,9 @@
import { Avatar } from '@mui/material'; import { Avatar } from '@astryxdesign/core/Avatar';
import { stringToColor, initials } from '@/utils/format';
// ==============================|| INITIALS AVATAR ||============================== // // ==============================|| INITIALS AVATAR ||============================== //
export default function UserAvatar({ name = '', size = 32, sx }) { export default function UserAvatar({ name = '', size = 32 }) {
return ( return (
<Avatar sx={{ width: size, height: size, bgcolor: stringToColor(name), fontSize: size * 0.42, ...sx }}> <Avatar name={name} size={size} />
{initials(name)}
</Avatar>
); );
} }

View File

@@ -1,5 +1,3 @@
import { Box, Stack, Typography } from '@mui/material';
// Dependency-free donut chart. data: [{ label, value, color }] // Dependency-free donut chart. data: [{ label, value, color }]
export default function DonutChart({ data = [], size = 180, thickness = 26, centerLabel, centerValue }) { export default function DonutChart({ data = [], size = 180, thickness = 26, centerLabel, centerValue }) {
const total = data.reduce((s, d) => s + d.value, 0) || 1; const total = data.reduce((s, d) => s + d.value, 0) || 1;
@@ -8,14 +6,18 @@ export default function DonutChart({ data = [], size = 180, thickness = 26, cent
let offset = 0; let offset = 0;
return ( return (
<Stack <div
direction={{ xs: 'column', sm: 'row' }} style={{
spacing={3} display: 'flex',
alignItems="center" flexDirection: 'row',
justifyContent="center" gap: '24px',
sx={{ flexWrap: 'wrap' }} 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}`}> <svg width="100%" height="100%" viewBox={`0 0 ${size} ${size}`}>
<g transform={`rotate(-90 ${size / 2} ${size / 2})`}> <g transform={`rotate(-90 ${size / 2} ${size / 2})`}>
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="#F0F0F0" strokeWidth={thickness} /> <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="#F0F0F0" strokeWidth={thickness} />
@@ -40,20 +42,20 @@ export default function DonutChart({ data = [], size = 180, thickness = 26, cent
})} })}
</g> </g>
</svg> </svg>
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}> <div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<Typography variant="h3" sx={{ fontWeight: 700 }}>{centerValue ?? total}</Typography> <div style={{ fontSize: '2rem', fontWeight: 700, color: '#1e293b' }}>{centerValue ?? total}</div>
<Typography variant="caption" color="text.secondary">{centerLabel ?? 'Total'}</Typography> <div style={{ fontSize: '0.75rem', color: '#64748b' }}>{centerLabel ?? 'Total'}</div>
</Box> </div>
</Box> </div>
<Stack spacing={1.25}> <div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{data.map((d) => ( {data.map((d) => (
<Stack key={d.label} direction="row" spacing={1} alignItems="center"> <div key={d.label} style={{ display: 'flex', flexDirection: 'row', gap: '8px', alignItems: 'center' }}>
<Box sx={{ width: 10, height: 10, borderRadius: '3px', bgcolor: d.color }} /> <div style={{ width: '10px', height: '10px', borderRadius: '3px', backgroundColor: d.color }} />
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 80 }}>{d.label}</Typography> <div style={{ fontSize: '0.875rem', color: '#64748b', minWidth: '80px' }}>{d.label}</div>
<Typography variant="subtitle2">{d.value.toLocaleString('en-IN')}</Typography> <div style={{ fontSize: '0.875rem', fontWeight: 600, color: '#1e293b' }}>{d.value.toLocaleString('en-IN')}</div>
</Stack> </div>
))} ))}
</Stack> </div>
</Stack> </div>
); );
} }

184
src/index.css Normal file
View 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;
}

View File

@@ -1,242 +1,78 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import { Settings, LogOut } from 'lucide-react';
AppBar, import { TopNav, TopNavHeading } from '@astryxdesign/core/TopNav';
Toolbar, import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
IconButton,
Box,
InputBase,
Avatar,
Typography,
Stack,
Menu,
MenuItem,
Divider,
ListItemIcon,
Popper,
Paper,
ClickAwayListener,
CircularProgress,
alpha
} from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import SearchIcon from '@mui/icons-material/Search';
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
import LogoutIcon from '@mui/icons-material/Logout';
import Logo from '@/components/Logo'; import Logo from '@/components/Logo';
import UserAvatar from '@/components/UserAvatar'; import UserAvatar from '@/components/UserAvatar';
import { fetchClients, fetchUsers } from '@/utils/apiClient'; import { fetchUsers } from '@/utils/apiClient';
import { toClient } from '@/utils/mappers';
const RED = '#C01227'; export default function Header({ isSidebarCollapsed }) {
export default function Header({ onToggle }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [account, setAccount] = useState(null);
const [search, setSearch] = useState('');
// Read user from localStorage
let storedUserObj = { name: 'Admin', role: 'Operations Admin', id: 0 }; let storedUserObj = { name: 'Admin', role: 'Operations Admin', id: 0 };
try { try {
const storedUser = localStorage.getItem('user'); const storedUser = localStorage.getItem('user');
if (storedUser) { if (storedUser) storedUserObj = JSON.parse(storedUser);
storedUserObj = JSON.parse(storedUser); } catch (e) { }
}
} catch(e) {}
const [activeUserName, setActiveUserName] = useState(storedUserObj.name || 'Admin'); const [activeUserName, setActiveUserName] = useState(storedUserObj.name || 'Admin');
const displayRole = storedUserObj.role === 'admin' ? 'Administrator' :
storedUserObj.role === 'manager' ? 'Manager' :
storedUserObj.role === 'executive' ? 'Executive' : 'Operations Admin';
const displayInitial = activeUserName.charAt(0).toUpperCase();
const searchRef = useRef(null);
const [clients, setClients] = useState([]);
const [loadedClients, setLoadedClients] = useState(false);
const [loadingClients, setLoadingClients] = useState(false);
const [openResults, setOpenResults] = useState(false);
// Fetch users to get the real name if the login payload didn't have it
useEffect(() => { useEffect(() => {
fetchUsers().then((users) => { fetchUsers().then((users) => {
if (storedUserObj.email) { if (storedUserObj.email) {
// Strictly use email to match, as stored ID might belong to the doormile_auth table instead of appusers table
const matchingUser = users.find(u => u.email === storedUserObj.email); const matchingUser = users.find(u => u.email === storedUserObj.email);
if (matchingUser && matchingUser.first_name) { if (matchingUser && matchingUser.first_name) {
setActiveUserName(matchingUser.first_name); setActiveUserName(matchingUser.first_name);
// Also update localStorage so it's fresh localStorage.setItem('user', JSON.stringify({ ...storedUserObj, name: matchingUser.first_name }));
const updatedUser = { ...storedUserObj, name: matchingUser.first_name };
localStorage.setItem('user', JSON.stringify(updatedUser));
} }
} }
}).catch(console.error); }).catch(console.error);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const ensureClients = () => {
if (loadedClients || loadingClients) return;
setLoadingClients(true);
fetchClients()
.then((points) => setClients(points.map(toClient)))
.catch(() => {})
.finally(() => { setLoadedClients(true); setLoadingClients(false); });
};
const q = search.trim().toLowerCase();
const results = q
? clients.filter((c) => [c.name, c.city, c.businessType, c.phone].join(' ').toLowerCase().includes(q)).slice(0, 6)
: [];
const onSearchChange = (e) => {
setSearch(e.target.value);
ensureClients();
setOpenResults(true);
};
const goToClients = (term) => {
navigate(`/tenants?q=${encodeURIComponent(term)}`);
setSearch('');
setOpenResults(false);
};
const submitSearch = (e) => {
e.preventDefault();
const term = search.trim();
if (term) goToClients(term);
};
return ( return (
<AppBar <TopNav
position="fixed" label="Main navigation"
elevation={0} style={{
sx={{ bgcolor: '#fff', color: 'grey.800', zIndex: (t) => t.zIndex.drawer + 1, borderBottom: 1, borderColor: 'divider' }} backgroundColor: '#ffffff',
> borderBottom: '1px solid rgba(5, 54, 89, 0.08)',
<Toolbar sx={{ minHeight: 64, px: { xs: 1.5, sm: 2.5 }, gap: 1 }}> boxShadow: '0 1px 3px rgba(15, 23, 42, 0.04)'
<IconButton color="inherit" edge="start" onClick={onToggle} sx={{ mr: 0.5 }}>
<MenuIcon />
</IconButton>
<Box
onClick={() => navigate('/dashboard')}
sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}
>
<Logo height={22} />
</Box>
<Box sx={{ flexGrow: 1 }} />
<ClickAwayListener onClickAway={() => setOpenResults(false)}>
<Box sx={{ display: { xs: 'none', sm: 'block' }, position: 'relative' }}>
<Box
ref={searchRef}
component="form"
onSubmit={submitSearch}
sx={{
display: 'flex',
alignItems: 'center',
bgcolor: 'grey.100',
borderRadius: 2,
px: 1.5,
py: 0.5,
width: { sm: 240, md: 320 },
'&:hover': { bgcolor: 'grey.200' },
'&:focus-within': { bgcolor: 'grey.200' }
}} }}
> heading={
<SearchIcon sx={{ fontSize: 20, mr: 1, color: 'grey.500' }} /> <TopNavHeading
<InputBase logo={
value={search} <Logo
onChange={onSearchChange} compact={isSidebarCollapsed}
onFocus={() => { ensureClients(); if (search.trim()) setOpenResults(true); }} size={isSidebarCollapsed ? 36 : 32}
placeholder="Search clients…" height={28}
sx={{ color: 'grey.800', fontSize: '0.875rem', flex: 1, '&::placeholder': { color: 'grey.500' } }} />
inputProps={{ 'aria-label': 'search' }} }
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>
}
/> />
</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: 'grey.100' } }}
>
<Avatar sx={{ width: 34, height: 34, bgcolor: RED, color: '#fff', fontWeight: 700 }}>{displayInitial}</Avatar>
<Box sx={{ display: { xs: 'none', md: 'block' }, lineHeight: 1.1 }}>
<Typography variant="subtitle2" sx={{ color: 'grey.800', fontWeight: 600 }}>
{activeUserName}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', textTransform: 'capitalize' }}>
{displayRole}
</Typography>
</Box>
</Box>
<Menu
anchorEl={account}
open={Boolean(account)}
onClose={() => setAccount(null)}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
PaperProps={{ sx: { mt: 1, minWidth: 220 } }}
>
<Box sx={{ px: 2, py: 1.5 }}>
<Stack direction="row" spacing={1.5} alignItems="center">
<Avatar sx={{ width: 38, height: 38, bgcolor: RED, color: '#fff', fontWeight: 700 }}>{displayInitial}</Avatar>
<Box sx={{ lineHeight: 1.2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>{activeUserName}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'capitalize' }}>{displayRole}</Typography>
</Box>
</Stack>
</Box>
<Divider />
<MenuItem onClick={() => { setAccount(null); navigate('/settings'); }}>
<ListItemIcon><SettingsOutlinedIcon fontSize="small" /></ListItemIcon>
Settings
</MenuItem>
<Divider />
<MenuItem onClick={() => { setAccount(null); localStorage.removeItem('auth_token'); navigate('/login'); }} sx={{ color: 'error.main' }}>
<ListItemIcon><LogoutIcon fontSize="small" color="error" /></ListItemIcon>
Logout
</MenuItem>
</Menu>
</Toolbar>
</AppBar>
); );
} }

View File

@@ -1,197 +1,169 @@
import { useState } from 'react'; import { useLocation } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom'; import { SideNav, SideNavSection, SideNavItem } from '@astryxdesign/core/SideNav';
import { import { Text } from '@astryxdesign/core/Text';
Drawer,
Box,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Typography,
Collapse,
Tooltip,
Toolbar,
alpha
} from '@mui/material';
import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
import navItems from '@/menu/navItems'; import navItems from '@/menu/navItems';
import Logo from '@/components/Logo';
export const DRAWER_WIDTH = 264; // ==============================|| DOORMILE - SIDE NAV ||============================== //
export const MINI_WIDTH = 78; // 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'; export default function Sidebar({ isCollapsed, onCollapsedChange }) {
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: active ? RED : 'grey.700',
'& .MuiListItemIcon-root': { color: active ? RED : 'grey.500' },
'&:hover': { bgcolor: alpha(RED, 0.04), color: RED, '& .MuiListItemIcon-root': { color: RED } },
'&.Mui-selected': {
bgcolor: alpha(RED, 0.08),
color: RED,
'& .MuiListItemIcon-root': { color: RED },
borderLeft: open ? `4px solid ${RED}` : 'none',
'&:hover': { bgcolor: alpha(RED, 0.12) }
}
}}
>
<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 }) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const isActive = (url) => !!url && location.pathname.startsWith(url);
const isActive = (url) => url && location.pathname.startsWith(url);
const expanded = open || isMobile;
const initialOpen = navItems return (
.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: '#fff', height: '100%', color: 'grey.800', display: 'flex', flexDirection: 'column', borderRight: 1, borderColor: 'divider' }}>
<Toolbar sx={{ px: expanded ? 2.5 : 0, justifyContent: expanded ? 'flex-start' : 'center', minHeight: 64 }}>
<Logo compact={!expanded} />
</Toolbar>
<Box sx={{ overflowY: 'auto', overflowX: 'hidden', flexGrow: 1, pb: 2 }}>
{navItems.map((grp) => (
<Box key={grp.group} sx={{ mt: 1 }}>
{expanded && (
<Typography
variant="overline"
sx={{ px: 2.5, color: '#A06060', fontSize: '0.6875rem', letterSpacing: '0.08em', fontWeight: 700 }}
>
{grp.group}
</Typography>
)}
<List disablePadding sx={{ mt: 0.5 }}>
{grp.items.map((item) => {
if (item.children) {
const opened = collapse.includes(item.id);
const childActive = item.children.some((c) => isActive(c.url));
const Icon = item.icon;
const head = (
<ListItemButton
onClick={() =>
expanded
? setCollapse((p) => (p.includes(item.id) ? p.filter((x) => x !== item.id) : [...p, item.id]))
: go(item.children[0].url)
}
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 ? RED : 'grey.700',
bgcolor: childActive && !opened ? alpha(RED, 0.08) : 'transparent',
'& .MuiListItemIcon-root': { color: childActive ? RED : 'grey.500' },
'&:hover': { bgcolor: alpha(RED, 0.04), color: RED, '& .MuiListItemIcon-root': { color: RED } }
}}
>
<ListItemIcon sx={{ minWidth: expanded ? 34 : 'auto', justifyContent: 'center' }}>
<Icon fontSize="small" />
</ListItemIcon>
{expanded && (
<> <>
<ListItemText primary={item.title} primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: 500 }} /> <SideNav
{opened ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />} className="doormile-side-nav"
</> collapsible={{ isCollapsed, onCollapsedChange, buttonLabel: 'Collapse navigation' }}
)} style={{
</ListItemButton> // White + a right border/shadow matches the TopNav's chrome so the
); // top and side nav read as one unified surface instead of two
return ( // mismatched panels (TopNav is white with a bottom border already).
<Box key={item.id}> backgroundColor: '#ffffff',
{expanded ? head : <Tooltip title={item.title} placement="right">{head}</Tooltip>} borderRight: '1px solid rgba(5, 54, 89, 0.08)',
{expanded && ( boxShadow: '1px 0 3px rgba(15, 23, 42, 0.03)',
<Collapse in={opened} timeout="auto" unmountOnExit> paddingBlock: '12px',
<Box sx={{ pl: 1.5 }}> paddingInline: '8px',
{item.children.map((c) => ( boxSizing: 'border-box',
<NavLeaf key={c.id} item={c} open depth={1} active={isActive(c.url)} onClick={() => go(c.url)} /> // Astryx's collapsed-rail width reads var(--spacing-12) (48px default),
))} // which feels cramped for our icons. Overriding the token on this
</Box> // element only widens the collapsed rail without touching the same
</Collapse> // token's unrelated uses elsewhere (e.g. AppShell's mobile top bar).
)} '--spacing-12': '72px'
</Box>
);
}
return (
<NavLeaf key={item.id} item={item} open={expanded} active={isActive(item.url)} onClick={() => go(item.url)} />
);
})}
</List>
</Box>
))}
</Box>
</Box>
);
if (isMobile) {
return (
<Drawer
variant="temporary"
open={mobileOpen}
onClose={onMobileClose}
ModalProps={{ keepMounted: true }}
sx={{ '& .MuiDrawer-paper': { width: DRAWER_WIDTH, border: 'none' } }}
>
{content}
</Drawer>
);
}
return (
<Drawer
variant="permanent"
sx={{
width: open ? DRAWER_WIDTH : MINI_WIDTH,
flexShrink: 0,
whiteSpace: 'nowrap',
'& .MuiDrawer-paper': {
width: open ? DRAWER_WIDTH : MINI_WIDTH,
border: 'none',
overflowX: 'hidden',
transition: (t) => t.transitions.create('width', { duration: t.transitions.duration.standard })
}
}} }}
open={open}
> >
{content} {navItems.map((grp) => (
</Drawer> <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;
}
/* 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>
</>
); );
} }

View File

@@ -1,45 +1,41 @@
import { useState } from 'react'; import { useState } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { Box, Toolbar, useMediaQuery } from '@mui/material'; import { AppShell } from '@astryxdesign/core/AppShell';
import { useTheme } from '@mui/material/styles';
import Header from './Header'; 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() { export default function MainLayout() {
const theme = useTheme(); // Lifted here (rather than left as SideNav's own uncontrolled state) so the
const isMobile = useMediaQuery(theme.breakpoints.down('lg')); // navbar's logo can track the sidebar's collapse state — one logo, not two.
const [open, setOpen] = useState(true); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(true);
const [mobileOpen, setMobileOpen] = useState(false);
const toggle = () => {
if (isMobile) setMobileOpen((p) => !p);
else setOpen((p) => !p);
};
return ( return (
<Box sx={{ display: 'flex', bgcolor: 'background.default', minHeight: '100vh' }}> <AppShell
<Header onToggle={toggle} /> variant="section"
<Sidebar height="fill"
open={open} contentPadding={0}
isMobile={isMobile} topNav={<Header isSidebarCollapsed={isSidebarCollapsed} />}
mobileOpen={mobileOpen} sideNav={<Sidebar isCollapsed={isSidebarCollapsed} onCollapsedChange={setIsSidebarCollapsed} />}
onMobileClose={() => setMobileOpen(false)} mobileNav={{ breakpoint: 'lg' }}
/>
<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 }} /> <div className="main-content-area" style={{ minHeight: '100%', boxSizing: 'border-box' }}>
<Box sx={{ p: { xs: 2, sm: 3 } }}>
<Outlet /> <Outlet />
</Box> </div>
</Box> <style>{`
</Box> .main-content-area {
padding: 24px;
}
@media (max-width: 480px) {
.main-content-area {
padding: 16px;
}
}
`}</style>
</AppShell>
); );
} }

View File

@@ -1,11 +1,10 @@
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { Box } from '@mui/material';
// Used by auth + maintenance pages — full-bleed, no shell. // Used by auth + maintenance pages — full-bleed, no shell.
export default function MinimalLayout() { export default function MinimalLayout() {
return ( return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}> <div style={{ minHeight: '100vh', backgroundColor: '#f8fafc' }}>
<Outlet /> <Outlet />
</Box> </div>
); );
} }

View File

@@ -1,22 +1,28 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter, Link } 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 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'; import App from '@/App';
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider theme={theme}> <Theme theme={neutralTheme}>
<CssBaseline />
<LocalizationProvider dateAdapter={AdapterDayjs}>
<BrowserRouter> <BrowserRouter>
{/* Makes every Astryx nav/link component (SideNavItem, TopNavHeading, DropdownMenu, ...)
route through React Router's Link instead of a hard <a> reload. */}
<LinkProvider component={Link}>
<App /> <App />
</LinkProvider>
</BrowserRouter> </BrowserRouter>
</LocalizationProvider> </Theme>
</ThemeProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -1,8 +1,4 @@
import DashboardOutlinedIcon from '@mui/icons-material/DashboardOutlined'; import { LayoutDashboard, Building2, Users, Settings, DollarSign, Calendar } from 'lucide-react';
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined';
import AttachMoneyOutlinedIcon from '@mui/icons-material/AttachMoneyOutlined';
// ==============================|| DOORMILE - SIDEBAR NAV CONFIG ||============================== // // ==============================|| DOORMILE - SIDEBAR NAV CONFIG ||============================== //
@@ -10,18 +6,18 @@ const navItems = [
{ {
group: 'CRM', group: 'CRM',
items: [ items: [
{ id: 'dashboard', title: 'Dashboard', url: '/dashboard', icon: DashboardOutlinedIcon }, { id: 'dashboard', title: 'Dashboard', url: '/dashboard', icon: LayoutDashboard },
{ id: 'tenants', title: 'Clients', url: '/tenants', icon: ApartmentOutlinedIcon }, { id: 'tenants', title: 'Clients', url: '/clients', icon: Building2 },
{ id: 'survey', title: 'Providers', url: '/survey', icon: DashboardOutlinedIcon }, { id: 'survey', title: 'Providers', url: '/survey', icon: Users },
{ id: 'pricing', title: 'Pricing Matrix', url: '/pricing', icon: AttachMoneyOutlinedIcon }, { id: 'pricing', title: 'Pricing Matrix', url: '/pricing', icon: DollarSign },
{ id: 'bookings', title: 'Bookings', url: '/bookings', icon: DashboardOutlinedIcon }, { id: 'bookings', title: 'Bookings', url: '/bookings', icon: Calendar },
{ id: 'team-users', title: 'App Users', url: '/app-users', icon: GroupsOutlinedIcon } { id: 'team-users', title: 'App Users', url: '/team-users', icon: Users }
] ]
}, },
{ {
group: 'System', group: 'System',
items: [ items: [
{ id: 'settings', title: 'Settings', url: '/settings', icon: SettingsOutlinedIcon } { id: 'settings', title: 'Settings', url: '/settings', icon: Settings }
] ]
} }
]; ];

View File

@@ -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 { import {
Grid, Card, Box, Stack, Typography, Button, Divider, LinearProgress, CircularProgress, Alert, Building2,
Table, TableBody, TableCell, TableHead, TableRow, TableContainer, useMediaQuery UserPlus,
} from '@mui/material'; Package,
import { useTheme } from '@mui/material/styles'; Handshake,
import RefreshIcon from '@mui/icons-material/Refresh'; History,
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined'; PieChart,
import FiberNewOutlinedIcon from '@mui/icons-material/FiberNewOutlined'; Layers,
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; Users,
import HandshakeOutlinedIcon from '@mui/icons-material/HandshakeOutlined'; ArrowRight,
import HistoryOutlinedIcon from '@mui/icons-material/HistoryOutlined'; Calendar
import DonutLargeOutlinedIcon from '@mui/icons-material/DonutLargeOutlined'; } from 'lucide-react';
import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined'; import { Button } from '@astryxdesign/core/Button';
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined'; 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 PageHeader from '@/components/PageHeader';
import StatCard from '@/components/StatCard'; import StatCard from '@/components/StatCard';
@@ -30,50 +37,86 @@ const BAR_COLORS = ['#C01227', '#00A2AE', '#00A854', '#FFBF00', '#9E0E20', '#8C8
function Panel({ icon: Icon, title, action, color = 'primary', noPadding = false, children }) { function Panel({ icon: Icon, title, action, color = 'primary', noPadding = false, children }) {
return ( return (
<Card sx={{ <Card
style={{
height: '100%', height: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
borderRadius: 4, borderRadius: '16px',
boxShadow: '0 10px 30px rgba(0,0,0,0.03)', border: '1px solid rgba(5, 54, 89, 0.08)',
border: '1px solid rgba(0,0,0,0.04)' backgroundColor: '#ffffff',
}}> overflow: 'hidden',
<Stack boxShadow: '0 10px 30px -5px rgba(0,0,0,0.02), 0 4px 12px -3px rgba(0,0,0,0.02)',
direction="row" spacing={1.5} alignItems="center" boxSizing: 'border-box'
sx={{
px: { xs: 2, sm: 2.5 }, py: 2, borderBottom: 1, borderColor: 'divider',
background: (theme) => `linear-gradient(90deg, ${theme.palette[color].lighter}66 0%, transparent 100%)`
}} }}
> >
<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)' }}> <div
<Icon fontSize="small" /> style={{
</Box> display: 'flex',
<Typography variant="h6" sx={{ fontWeight: 700, color: 'grey.800', flexGrow: 1, letterSpacing: '-0.3px' }}>{title}</Typography> 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'
}}
>
<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} {action}
</Stack> </div>
<Box sx={{ p: noPadding ? 0 : { xs: 2, sm: 3 }, flexGrow: 1 }}>{children}</Box> <div style={{ padding: noPadding ? '0' : '16px', flexGrow: 1, boxSizing: 'border-box' }}>
{children}
</div>
</Card> </Card>
); );
} }
export default function Dashboard() { export default function Dashboard() {
const theme = useTheme(); const navigate = useNavigate();
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const [selectedDate, setSelectedDate] = useState(() => dayjs().format('YYYY-MM-DD'));
const [clients, setClients] = useState([]); const [clients, setClients] = useState([]);
const [team, setTeam] = useState([]); const [team, setTeam] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Guards against out-of-order responses (e.g. React StrictMode's double-invoked
// effect) so a stale request can never clobber a newer one's result.
const loadRequestId = useRef(0);
const load = () => { const load = () => {
const requestId = ++loadRequestId.current;
setLoading(true); setLoading(true);
setError(null); setError(null);
Promise.all([fetchClients(), fetchUsers()]) Promise.all([fetchClients(), fetchUsers()])
.then(([cs, us]) => { .then(([cs, us]) => {
if (loadRequestId.current !== requestId) return;
setClients((cs || []).map(toClient)); setClients((cs || []).map(toClient));
setTeam((us || []).map(toUser)); setTeam((us || []).map(toUser));
}) })
.catch((e) => setError(e.message || 'Failed to load dashboard data')) .catch((e) => {
.finally(() => setLoading(false)); if (loadRequestId.current === requestId) setError(e.message || 'Failed to load dashboard data');
})
.finally(() => {
if (loadRequestId.current === requestId) setLoading(false);
});
}; };
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
@@ -103,146 +146,287 @@ export default function Dashboard() {
[clients] [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) { if (loading) {
return ( return (
<> <div style={{ fontFamily: 'system-ui, sans-serif' }}>
<PageHeader title="Dashboard" breadcrumbs={[{ label: 'Dashboard' }]} /> <PageHeader title="Dashboard" action={datePickerAction} />
<Box sx={{ display: 'flex', justifyContent: 'center', py: 12 }}><CircularProgress /></Box> <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 ( return (
<> <div style={{ fontFamily: 'system-ui, sans-serif' }}>
<PageHeader <PageHeader title="Dashboard" action={datePickerAction} />
title="Dashboard"
breadcrumbs={[{ label: 'Dashboard' }]} {error && (
action={<Button variant="outlined" startIcon={<RefreshIcon />} onClick={load}>Refresh</Button>} <div style={{ marginBottom: '24px' }}>
<Banner
status="error"
title={error}
endContent={<Button label="Reload" variant="ghost" size="sm" onClick={load} />}
/> />
</div>
)}
{error && <Alert severity="error" sx={{ mb: 2.5 }} action={<Button color="inherit" size="small" onClick={load}>Retry</Button>}>{error}</Alert>} {/* 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>
<Grid container spacing={2.5}> <Divider />
<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>
<Grid item xs={12} lg={8}> {/* Clients section */}
<Panel icon={HistoryOutlinedIcon} title="Recent Clients" color="primary" noPadding> <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 ? ( {recent.length === 0 ? (
<EmptyState title="No clients yet" caption="Add a client to see it here." /> <EmptyState icon={Building2} title="No clients yet" caption="Add a client under the Clients menu 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> <div style={{ paddingTop: '8px', paddingBottom: '8px' }}>
<Table sx={{ minWidth: 600 }}> <Table
<TableHead> data={recent}
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', textTransform: 'uppercase', fontSize: '0.72rem', letterSpacing: 0.4 } }}> columns={recentColumns}
<TableCell>Client</TableCell> idKey="id"
<TableCell>Type</TableCell> density="compact"
<TableCell>Location</TableCell> dividers="rows"
<TableCell align="right">Parcels</TableCell> hasHover
<TableCell>Status</TableCell> isStriped
</TableRow> textOverflow="truncate"
</TableHead>
<TableBody>
{recent.map((c) => (
<TableRow key={c.id} hover>
<TableCell>
<Stack direction="row" spacing={1.25} alignItems="center">
<UserAvatar name={c.name} size={32} />
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }}>{c.name}</Typography>
</Stack>
</TableCell>
<TableCell><Typography variant="body2">{titleCase(c.businessType) || '—'}</Typography></TableCell>
<TableCell><Typography variant="body2">{c.city || '—'}{c.businessState ? `, ${c.businessState}` : ''}</Typography></TableCell>
<TableCell align="right" sx={{ fontWeight: 600 }}>{c.parcelVolume.toLocaleString('en-IN')}</TableCell>
<TableCell><StatusChip status={c.status} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Panel>
</Grid>
<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>
<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}>
{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> </div>
))}
</Stack>
)} )}
</Panel> </Panel>
</Grid> </div>
<Grid item xs={12} lg={4}> {/* Clients by Status */}
<Panel icon={GroupsOutlinedIcon} title={`App Users · ${team.length}`} color="primary"> <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>
))}
</div>
)}
</Panel>
</div>
{/* App Users */}
<div style={{ gridColumn: 'span 6' }} className="col-span-6">
<Panel
icon={Users}
title="App Users"
color="primary"
>
{team.length === 0 ? ( {team.length === 0 ? (
<EmptyState title="No team users" /> <EmptyState title="No team users" />
) : ( ) : (
<Stack divider={<Divider />} spacing={0}> <div style={{ display: 'flex', flexDirection: 'column', maxHeight: '324px', overflowY: 'auto', overflowX: 'hidden' }}>
{team.slice(0, 6).map((u) => ( {team.map((u) => (
<Stack key={u.id} direction="row" spacing={1.5} alignItems="center" sx={{ py: 1.5 }}> <div
<UserAvatar name={u.name} size={36} /> key={u.id}
<Box sx={{ flexGrow: 1, minWidth: 0 }}> style={{
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>{u.name}</Typography> display: 'flex',
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>{u.email}</Typography> alignItems: 'center',
</Box> 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} /> <StatusChip status={u.role} />
</Stack> </div>
))} ))}
</Stack> </div>
)} )}
</Panel> </Panel>
</Grid> </div>
</Grid> </div>
</> </div>
<style>{`
@media (min-width: 1024px) {
.responsive-row {
grid-template-columns: repeat(12, 1fr) !important;
}
.col-span-8 {
grid-column: span 8 !important;
}
.col-span-4 {
grid-column: span 4 !important;
}
.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;
}
/* Team member row hover polish */
.team-row-hover:hover {
background-color: rgba(10, 19, 23, 0.04) !important;
}
`}</style>
</div>
); );
} }

View File

@@ -1,22 +1,28 @@
import { useState } from 'react'; import { useState, useRef } from 'react';
import { import {
Grid, Card, Box, Stack, TextField, MenuItem, Switch, Button, Typography, Divider, Save,
Snackbar, Alert, Chip, IconButton, InputAdornment, LinearProgress, Avatar Sliders,
} from '@mui/material'; Bell,
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined'; Lock,
import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined'; Shield,
import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone'; Megaphone,
import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; Building,
import ShieldOutlinedIcon from '@mui/icons-material/ShieldOutlined'; CheckCircle2,
import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined'; AlertTriangle,
import BusinessOutlinedIcon from '@mui/icons-material/BusinessOutlined'; HelpCircle,
import VerifiedOutlinedIcon from '@mui/icons-material/VerifiedOutlined'; LogOut,
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; Eye,
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded'; EyeOff
import LogoutOutlinedIcon from '@mui/icons-material/LogoutOutlined'; } from 'lucide-react';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import { Button } from '@astryxdesign/core/Button';
import Visibility from '@mui/icons-material/Visibility'; import { Card } from '@astryxdesign/core/Card';
import VisibilityOff from '@mui/icons-material/VisibilityOff'; 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'; import PageHeader from '@/components/PageHeader';
@@ -35,10 +41,28 @@ const INITIAL_NOTIFY = {
}; };
const INITIAL_SECURITY = { currentPassword: '', newPassword: '', confirmPassword: '', twoFactor: false }; 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 = [ const NAV = [
{ icon: TuneOutlinedIcon, label: 'General', desc: 'Organisation profile' }, { icon: Sliders, label: 'General', desc: 'Organisation profile' },
{ icon: NotificationsNoneIcon, label: 'Notifications', desc: 'Alerts & channels' }, { icon: Bell, label: 'Notifications', desc: 'Alerts & channels' },
{ icon: LockOutlinedIcon, label: 'Security', desc: 'Password & 2FA' } { icon: Lock, label: 'Security', desc: 'Password & 2FA' }
]; ];
const NOTIFY_ROWS = [ const NOTIFY_ROWS = [
@@ -49,12 +73,13 @@ const NOTIFY_ROWS = [
]; ];
const STRENGTH = [ const STRENGTH = [
{ label: 'Too weak', color: 'error' }, { label: 'Too weak', color: '#ef4444' },
{ label: 'Weak', color: 'error' }, { label: 'Weak', color: '#ef4444' },
{ label: 'Fair', color: 'warning' }, { label: 'Fair', color: '#f59e0b' },
{ label: 'Good', color: 'info' }, { label: 'Good', color: '#3b82f6' },
{ label: 'Strong', color: 'success' } { label: 'Strong', color: '#10b981' }
]; ];
const scorePassword = (pw) => { const scorePassword = (pw) => {
let s = 0; let s = 0;
if (pw.length >= 8) s++; if (pw.length >= 8) s++;
@@ -64,63 +89,92 @@ const scorePassword = (pw) => {
return s; return s;
}; };
// Section surface with a tinted icon header band. function Section({ icon: Icon, title, subtitle, danger = false, children }) {
function Section({ icon: Icon, title, subtitle, color = 'primary', danger = false, children }) {
return ( return (
<Card sx={danger ? { borderColor: 'error.light' } : undefined}> <Card style={{ padding: '0', borderRadius: '16px', border: danger ? '1px solid #fca5a5' : '1px solid rgba(5, 54, 89, 0.08)', backgroundColor: '#ffffff', overflow: 'hidden' }}>
<Stack <div
direction="row" spacing={1.75} alignItems="center" style={{
sx={{ display: 'flex',
px: { xs: 2, sm: 3 }, py: 2, borderBottom: 1, borderColor: 'divider', flexDirection: 'row',
background: (theme) => `linear-gradient(90deg, ${theme.palette[color].lighter}66 0%, ${theme.palette.background.paper} 72%)` 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` }}> <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 fontSize="small" /> <Icon size={18} />
</Box> </div>
<Box sx={{ minWidth: 0 }}> <div>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.3 }}>{title}</Typography> <Heading level={3}>{title}</Heading>
{subtitle && <Typography variant="caption" color="text.secondary">{subtitle}</Typography>} {subtitle && <Text type="supporting" color="secondary">{subtitle}</Text>}
</Box> </div>
</Stack> </div>
<Box sx={{ px: { xs: 2, sm: 3 } }}>{children}</Box> <div style={{ padding: '4px 24px' }}>{children}</div>
</Card> </Card>
); );
} }
// Two-column row: label + helper on the left, control on the right. function Row({ label, description, controlAlign = 'stretch', children }) {
function Row({ label, description, children, align = 'center' }) {
return ( return (
<Grid <>
container spacing={2} alignItems={align} <div style={{ display: 'flex', alignItems: 'center', padding: '16px 0', flexWrap: 'wrap', gap: '12px' }}>
sx={{ py: 2.5, borderRadius: 2, transition: 'background-color .15s', '&:hover': { bgcolor: 'grey.50' } }} <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
}}
> >
<Grid item xs={12} sm={5}> {children}
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'grey.800' }}>{label}</Typography> </div>
{description && <Typography variant="caption" color="text.secondary">{description}</Typography>} </div>
</Grid> <Divider className="settings-row-divider" />
<Grid item xs={12} sm={7}>{children}</Grid> </>
</Grid>
); );
} }
const rightAlign = { display: 'flex', justifyContent: { sm: 'flex-end' } }; function PasswordField({ label, value, onChange }) {
function PasswordField({ label, value, onChange, autoComplete }) {
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
return ( return (
<TextField <div style={{ position: 'relative', width: '100%' }}>
fullWidth size="small" type={show ? 'text' : 'password'} label={label} value={value} onChange={onChange} autoComplete={autoComplete} <TextInput
InputProps={{ label={label}
endAdornment: ( isLabelHidden
<InputAdornment position="end"> type={show ? 'text' : 'password'}
<IconButton onClick={() => setShow((s) => !s)} edge="end" size="small"> placeholder={label}
{show ? <VisibilityOff fontSize="small" /> : <Visibility fontSize="small" />} value={value}
</IconButton> onChange={onChange}
</InputAdornment>
)
}}
/> />
<button
type="button"
onClick={() => setShow((s) => !s)}
aria-label={show ? 'Hide password' : 'Show password'}
style={{
position: 'absolute',
right: '10px',
bottom: 0,
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#64748b',
padding: 0,
zIndex: 5
}}
>
{show ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
); );
} }
@@ -129,19 +183,58 @@ export default function Settings() {
const [toast, setToast] = useState(false); const [toast, setToast] = useState(false);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [general, setGeneral] = useState(INITIAL_GENERAL); // savedRef tracks the last-persisted snapshot so Discard reverts to "what's actually
const [notify, setNotify] = useState(INITIAL_NOTIFY); // saved" rather than jumping back to hardcoded factory defaults.
const [security, setSecurity] = useState(INITIAL_SECURITY); const savedRef = useRef(loadSavedSettings());
const saved = savedRef.current;
const setG = (k) => (e) => { setGeneral((p) => ({ ...p, [k]: e.target.value })); setDirty(true); }; const [general, setGeneral] = useState(saved?.general || INITIAL_GENERAL);
const setN = (k) => (e) => { setNotify((p) => ({ ...p, [k]: e.target.checked })); setDirty(true); }; const [notify, setNotify] = useState(saved?.notify || INITIAL_NOTIFY);
const setSText = (k) => (e) => { setSecurity((p) => ({ ...p, [k]: e.target.value })); setDirty(true); }; 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 = () => { const discard = () => {
setGeneral(INITIAL_GENERAL); const last = savedRef.current;
setNotify(INITIAL_NOTIFY); setGeneral(last?.general || INITIAL_GENERAL);
setSecurity(INITIAL_SECURITY); setNotify(last?.notify || INITIAL_NOTIFY);
setSecurity({ ...INITIAL_SECURITY, twoFactor: last?.twoFactor ?? false });
setLogoUrl(last?.logoUrl ?? null);
setLogoError('');
setDirty(false); setDirty(false);
}; };
@@ -150,211 +243,227 @@ export default function Settings() {
const mismatch = security.confirmPassword && security.newPassword !== security.confirmPassword; const mismatch = security.confirmPassword && security.newPassword !== security.confirmPassword;
return ( return (
<> <div style={{ fontFamily: 'system-ui, sans-serif' }}>
<PageHeader <PageHeader
title="Settings" title="Settings"
breadcrumbs={[{ label: 'Settings' }]}
action={ action={
<Stack <div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
direction="row" spacing={1.5} alignItems="center" useFlexGap flexWrap="wrap" {dirty && <Badge variant="warning" label="Unsaved changes" />}
sx={{ width: { xs: '100%', sm: 'auto' }, justifyContent: { xs: 'flex-start', sm: 'flex-end' } }} <Button variant="ghost" onClick={discard} label="Discard" />
> <Button variant="primary" icon={<Save size={14} />} onClick={save} label="Save Changes" />
{dirty && <Chip size="small" label="Unsaved changes" sx={{ bgcolor: 'warning.lighter', color: 'warning.dark', fontWeight: 600 }} />} </div>
<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>
} }
/> />
<Grid container spacing={2.5}> <div style={{ display: 'grid', gridTemplateColumns: '1fr', lg: 'repeat(12, 1fr)', gap: '24px', marginTop: '24px' }} className="responsive-row">
{/* Sidebar */} {/* Sidebar */}
<Grid item xs={12} md={3}> <div style={{ gridColumn: 'span 3' }} className="col-span-3">
<Stack spacing={2.5} sx={{ position: { md: 'sticky' }, top: { md: 88 } }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<Card sx={{ p: 2 }}> <Card style={{ padding: '8px' }}>
<Typography variant="overline" sx={{ px: 1, color: 'text.secondary', fontWeight: 700, letterSpacing: 0.6 }}>Preferences</Typography> <List density="spacious">
<Stack spacing={0.25} sx={{ mt: 0.5 }}> {NAV.map((item, i) => (
{NAV.map((item, i) => { <ListItem
const active = tab === i;
const Icon = item.icon;
return (
<Stack
key={item.label} key={item.label}
direction="row" spacing={1.5} alignItems="center" label={item.label}
description={item.desc}
startContent={<item.icon size={16} />}
isSelected={tab === i}
onClick={() => setTab(i)} onClick={() => setTab(i)}
sx={{ />
px: 1.5, py: 1.5, borderRadius: 2, cursor: 'pointer', position: 'relative', ))}
bgcolor: active ? 'primary.lighter' : 'transparent', </List>
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>
</Card> </Card>
<Card sx={{ p: 2, bgcolor: 'primary.lighter', borderColor: 'primary.100' }}> <Card style={{ padding: '20px', backgroundColor: 'rgba(10, 19, 23, 0.05)', border: '1px solid rgba(10, 19, 23, 0.1)' }}>
<Stack spacing={1.25}> <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<Box sx={{ width: 38, height: 38, borderRadius: 2, bgcolor: 'primary.main', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ width: '38px', height: '38px', borderRadius: '8px', backgroundColor: 'rgba(10, 19, 23, 0.08)', color: '#0A1317', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<HelpOutlineRoundedIcon fontSize="small" /> <HelpCircle size={16} />
</Box> </div>
<Box> <div>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'primary.dark' }}>Need a hand?</Typography> <Text type="body" weight="bold" color="accent">Need a hand?</Text>
<Typography variant="caption" sx={{ color: 'primary.dark', opacity: 0.85 }}>Our team is available 24/7 for operational support.</Typography> <div style={{ marginTop: '2px' }}>
</Box> <Text type="supporting" color="accent">Our team is available 24/7 for operational support.</Text>
<Button size="small" variant="contained" sx={{ alignSelf: 'flex-start' }}>Contact support</Button> </div>
</Stack> </div>
<Button size="sm" variant="primary" label="Contact support" />
</div>
</Card> </Card>
</Stack> </div>
</Grid> </div>
{/* Content */} {/* Content */}
<Grid item xs={12} md={9}> <div style={{ gridColumn: 'span 9' }} className="col-span-9">
{tab === 0 && ( {tab === 0 && (
<Stack spacing={2.5}> <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Organisation identity banner */} {/* Org banner */}
<Card sx={{ overflow: 'hidden' }}> <Card style={{ padding: '0', overflow: 'hidden' }}>
<Stack <div
direction={{ xs: 'column', sm: 'row' }} spacing={2.5} alignItems={{ sm: 'center' }} style={{
sx={{ p: 3, background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}88 0%, ${theme.palette.background.paper} 75%)` }} 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' }}> <div
<BusinessOutlinedIcon /> style={{ width: '64px', height: '64px', borderRadius: '8px', backgroundColor: logoUrl ? 'transparent' : '#0A1317', color: '#ffffff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, overflow: 'hidden' }}
</Avatar> >
<Box sx={{ flexGrow: 1, minWidth: 0 }}> {logoUrl ? (
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap> <img src={logoUrl} alt="Organisation logo" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<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' } }} /> <Building size={32} />
</Stack> )}
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>{general.supportEmail} · {general.contact}</Typography> </div>
</Box> <div style={{ flexGrow: 1, minWidth: 0 }}>
<Button variant="outlined" size="small">Change logo</Button> <div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
</Stack> <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> </Card>
<Section icon={TuneOutlinedIcon} title="Organisation" subtitle="Profile and regional preferences" color="primary"> <Section icon={Sliders} title="Organisation" subtitle="Profile and regional preferences">
<Stack divider={<Divider />}>
<Row label="Organisation name" description="Shown on invoices and exports"> <Row label="Organisation name" description="Shown on invoices and exports">
<TextField fullWidth size="small" value={general.orgName} onChange={setG('orgName')} /> <TextInput label="Organisation name" isLabelHidden width="100%" value={general.orgName} onChange={setG('orgName')} />
</Row> </Row>
<Row label="Support email" description="Where customer replies are routed"> <Row label="Support email" description="Where customer replies are routed">
<TextField fullWidth size="small" value={general.supportEmail} onChange={setG('supportEmail')} /> <TextInput label="Support email" isLabelHidden width="100%" type="email" value={general.supportEmail} onChange={setG('supportEmail')} />
</Row> </Row>
<Row label="Contact number" description="Primary operations line"> <Row label="Contact number" description="Primary operations line">
<TextField fullWidth size="small" value={general.contact} onChange={setG('contact')} /> <TextInput label="Contact number" isLabelHidden width="100%" value={general.contact} onChange={setG('contact')} />
</Row> </Row>
<Row label="Timezone" description="Used for schedules and reports"> <Row label="Timezone" description="Used for schedules and reports">
<TextField select fullWidth size="small" value={general.timezone} onChange={setG('timezone')}> <div style={{ width: '100%' }}>
{TIMEZONES.map((t) => <MenuItem key={t} value={t}>{t}</MenuItem>)} <Selector label="Timezone" isLabelHidden options={TIMEZONES} value={general.timezone} onChange={(v) => setG('timezone')(v || '')} />
</TextField> </div>
</Row> </Row>
<Row label="Language" description="Console display language"> <Row label="Language" description="Console display language">
<TextField select fullWidth size="small" value={general.language} onChange={setG('language')}> <div style={{ width: '100%' }}>
{LANGUAGES.map((l) => <MenuItem key={l} value={l}>{l}</MenuItem>)} <Selector label="Language" isLabelHidden options={LANGUAGES} value={general.language} onChange={(v) => setG('language')(v || '')} />
</TextField> </div>
</Row> </Row>
</Stack>
</Section> </Section>
</Stack> </div>
)} )}
{tab === 1 && ( {tab === 1 && (
<Stack spacing={2.5}> <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<Section icon={NotificationsNoneIcon} title="Notification Preferences" subtitle="Choose what you get alerted about" color="primary"> <Section icon={Bell} title="Notification Preferences" subtitle="Choose what you get alerted about">
<Stack divider={<Divider />}>
{NOTIFY_ROWS.map((row) => ( {NOTIFY_ROWS.map((row) => (
<Row key={row.k} label={row.t} description={row.d}> <Row key={row.k} label={row.t} description={row.d} controlAlign="end">
<Box sx={rightAlign}><Switch checked={notify[row.k]} onChange={setN(row.k)} /></Box> <Switch label={row.t} isLabelHidden value={notify[row.k]} onChange={setN(row.k)} />
</Row> </Row>
))} ))}
</Stack>
</Section> </Section>
<Section icon={CampaignOutlinedIcon} title="Delivery Channels" subtitle="How alerts reach your team" color="primary"> <Section icon={Megaphone} title="Delivery Channels" subtitle="How alerts reach your team">
<Stack divider={<Divider />}> <Row label="Email alerts" description="Send notifications to the support inbox" controlAlign="end">
<Row label="Email alerts" description="Send notifications to the support inbox"> <Switch label="Email alerts" isLabelHidden value={notify.emailAlerts} onChange={setN('emailAlerts')} />
<Box sx={rightAlign}><Switch checked={notify.emailAlerts} onChange={setN('emailAlerts')} /></Box>
</Row> </Row>
<Row label="SMS alerts" description="Send notifications to the registered mobile"> <Row label="SMS alerts" description="Send notifications to the registered mobile" controlAlign="end">
<Box sx={rightAlign}><Switch checked={notify.smsAlerts} onChange={setN('smsAlerts')} /></Box> <Switch label="SMS alerts" isLabelHidden value={notify.smsAlerts} onChange={setN('smsAlerts')} />
</Row> </Row>
</Stack>
</Section> </Section>
</Stack> </div>
)} )}
{tab === 2 && ( {tab === 2 && (
<Stack spacing={2.5}> <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<Section icon={LockOutlinedIcon} title="Change Password" subtitle="Use 8+ characters with a mix of letters, numbers & symbols" color="primary"> <Section icon={Lock} title="Change Password" subtitle="Use 8+ characters with a mix of letters, numbers & symbols">
<Stack divider={<Divider />}> <Row label="Current password" description="Enter your existing password">
<Row label="Current password" description="Enter your existing password" align="flex-start"> <PasswordField label="Current password" value={security.currentPassword} onChange={setSText('currentPassword')} />
<PasswordField label="Current password" value={security.currentPassword} onChange={setSText('currentPassword')} autoComplete="current-password" />
</Row> </Row>
<Row label="New password" description="Choose a strong, unique password" align="flex-start"> <Row label="New password" description="Choose a strong, unique password">
<Box> <div style={{ display: 'flex', flexDirection: 'column', gap: '6px', width: '100%' }}>
<PasswordField label="New password" value={security.newPassword} onChange={setSText('newPassword')} autoComplete="new-password" /> <PasswordField label="New password" value={security.newPassword} onChange={setSText('newPassword')} />
{security.newPassword && ( {security.newPassword && (
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mt: 1 }}> <div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginTop: '4px' }}>
<LinearProgress variant="determinate" value={(pwScore / 4) * 100} color={pwMeta.color} sx={{ flexGrow: 1, height: 6, borderRadius: 3 }} /> <div style={{ flexGrow: 1, height: '6px', borderRadius: '3px', backgroundColor: '#f1f5f9', overflow: 'hidden' }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: `${pwMeta.color}.main`, minWidth: 56 }}>{pwMeta.label}</Typography> <div style={{ height: '100%', backgroundColor: pwMeta.color, width: `${(pwScore / 4) * 100}%` }} />
</Stack> </div>
<span style={{ fontSize: '0.75rem', fontWeight: 700, color: pwMeta.color, minWidth: '56px' }}>{pwMeta.label}</span>
</div>
)} )}
</Box> </div>
</Row> </Row>
<Row label="Confirm new password" description="Re-enter the new password" align="flex-start"> <Row label="Confirm new password" description="Re-enter the new password">
<Box> <div style={{ display: 'flex', flexDirection: 'column', gap: '4px', width: '100%' }}>
<PasswordField label="Confirm new password" value={security.confirmPassword} onChange={setSText('confirmPassword')} autoComplete="new-password" /> <PasswordField label="Confirm new password" value={security.confirmPassword} onChange={setSText('confirmPassword')} />
{mismatch && <Typography variant="caption" color="error.main" sx={{ mt: 0.75, display: 'block' }}>Passwords do not match</Typography>} {mismatch && <span style={{ fontSize: '0.75rem', color: '#ef4444', display: 'block', marginTop: '4px' }}>Passwords do not match</span>}
</Box> </div>
</Row> </Row>
</Stack>
</Section> </Section>
<Section icon={ShieldOutlinedIcon} title="Two-Factor Authentication" subtitle="Add an extra layer of security to your account" color="primary"> <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"> <Row label="Authenticator app" description="Require a one-time code at sign-in for extra security" controlAlign="end">
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ justifyContent: { sm: 'flex-end' } }}> <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Chip <Badge variant={security.twoFactor ? 'success' : 'neutral'} label={security.twoFactor ? 'Enabled' : 'Disabled'} />
size="small" <Switch
icon={<ShieldOutlinedIcon sx={{ fontSize: 15, ml: 0.5 }} />} label="Two-factor authentication"
label={security.twoFactor ? 'Enabled' : 'Disabled'} isLabelHidden
sx={{ fontWeight: 700, bgcolor: security.twoFactor ? 'success.lighter' : 'grey.100', color: security.twoFactor ? 'success.dark' : 'grey.600', '& .MuiChip-icon': { color: 'inherit' } }} 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); }} /> </div>
</Stack>
</Row> </Row>
</Section> </Section>
<Section icon={WarningAmberRoundedIcon} title="Danger Zone" subtitle="Irreversible and high-impact actions" color="error" danger> <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"> <Row label="Sign out of all sessions" description="End every active session on all devices" controlAlign="end">
<Box sx={rightAlign}> <Button variant="destructive" onClick={() => { localStorage.removeItem('auth_token'); window.location.href = '/login'; }} label="Sign out everywhere" icon={<LogOut size={14} />} />
<Button variant="outlined" color="error" startIcon={<LogoutOutlinedIcon />} onClick={() => { localStorage.removeItem('auth_token'); window.location.href = '/login'; }}>Sign out everywhere</Button>
</Box>
</Row> </Row>
</Section> </Section>
</Stack> </div>
)} )}
</Grid> </div>
</Grid> </div>
<Snackbar {toast && (
open={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' }}>
autoHideDuration={2500} <CheckCircle2 size={16} style={{ color: '#10b981' }} />
onClose={() => setToast(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="success" variant="filled" onClose={() => setToast(false)} sx={{ width: '100%' }}>
Settings saved successfully. Settings saved successfully.
</Alert> </div>
</Snackbar> )}
</>
<style>{`
@media (min-width: 1024px) {
.responsive-row {
grid-template-columns: repeat(12, 1fr) !important;
}
.col-span-3 {
grid-column: span 3 !important;
}
.col-span-9 {
grid-column: span 9 !important;
}
}
@media (max-width: 1023px) {
.responsive-row {
grid-template-columns: 1fr !important;
}
.col-span-3, .col-span-9 {
grid-column: span 1 !important;
}
}
.settings-row-divider:last-child {
display: none;
}
`}</style>
</div>
); );
} }

View File

@@ -1,25 +1,18 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import { Eye, EyeOff, Mail, Lock } from 'lucide-react';
Box, import { Center } from '@astryxdesign/core/Center';
Grid, import { Card } from '@astryxdesign/core/Card';
Card, import { VStack, HStack } from '@astryxdesign/core/Layout';
Stack, import { Text } from '@astryxdesign/core/Text';
Typography, import { TextInput } from '@astryxdesign/core/TextInput';
TextField, import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
InputAdornment, import { Button } from '@astryxdesign/core/Button';
IconButton, import { Link } from '@astryxdesign/core/Link';
Button, import { Banner } from '@astryxdesign/core/Banner';
Checkbox,
FormControlLabel,
Link
} from '@mui/material';
import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff';
import Logo from '@/components/Logo'; import Logo from '@/components/Logo';
import bgImage from '../../assets/mid-mile-approach.jpg'; import bgImage from '../../assets/mid-mile-approach.jpg';
import { loginAdmin } from '@/utils/apiClient'; import { loginAdmin } from '@/utils/apiClient';
export default function Login() { export default function Login() {
@@ -28,10 +21,14 @@ export default function Login() {
const [auth, setAuth] = useState(''); const [auth, setAuth] = useState('');
const [pwd, setPwd] = useState(''); const [pwd, setPwd] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [rememberMe, setRememberMe] = useState(true);
const [loading, setLoading] = useState(false);
const handleLogin = async () => { const handleLogin = async () => {
try { if (!auth || !pwd || loading) return;
setLoading(true);
setError(''); setError('');
try {
const data = await loginAdmin(auth, pwd); const data = await loginAdmin(auth, pwd);
localStorage.setItem('logged_in', 'true'); localStorage.setItem('logged_in', 'true');
localStorage.setItem('auth_token', data.token); localStorage.setItem('auth_token', data.token);
@@ -39,6 +36,8 @@ export default function Login() {
navigate('/dashboard'); navigate('/dashboard');
} catch (err) { } catch (err) {
setError(err.message || 'Invalid email or password'); setError(err.message || 'Invalid email or password');
} finally {
setLoading(false);
} }
}; };
@@ -47,132 +46,128 @@ export default function Login() {
}; };
return ( return (
<Box <div
sx={{ style={{
minHeight: '100vh', minHeight: '100vh',
display: 'flex', position: 'relative',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: `url(${bgImage})`, backgroundImage: `url(${bgImage})`,
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
position: 'relative', fontFamily: 'system-ui, sans-serif'
'&::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
}
}} }}
> >
<div
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(180deg, rgba(15,23,42,0.6) 0%, rgba(10, 19, 23, 0.8) 100%)'
}}
/>
<Center axis="both" style={{ position: 'relative', minHeight: '100vh', padding: '16px', boxSizing: 'border-box' }}>
<Card <Card
sx={{ padding={8}
position: 'relative', width="100%"
zIndex: 2, maxWidth={440}
width: '100%', style={{
maxWidth: 440, background: 'rgba(255, 255, 255, 0.9)',
p: { xs: 4, sm: 5 },
m: 2,
background: 'rgba(255, 255, 255, 0.85)',
backdropFilter: 'blur(24px)', backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
border: '1px solid rgba(255, 255, 255, 0.5)', border: '1px solid rgba(255, 255, 255, 0.5)',
boxShadow: '0 24px 48px rgba(0,0,0,0.2)', boxShadow: '0 24px 48px rgba(0,0,0,0.2)',
borderRadius: 4, boxSizing: 'border-box'
}} }}
> >
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'center' }}><Logo /></Box> <VStack gap={4} hAlign="stretch">
<Typography variant="h3" sx={{ fontWeight: 800, textAlign: 'center', letterSpacing: '-0.5px', color: '#1e293b' }}>Welcome back</Typography> <VStack gap={1} hAlign="center">
<Typography variant="body1" sx={{ mt: 1, mb: 4, textAlign: 'center', color: '#475569' }}> <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. Sign in to your Doormile operations account.
</Typography> </Text>
</VStack>
<Stack spacing={2.5}> {error && <Banner status="error" title={error} />}
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.75, color: '#334155', fontWeight: 600 }}>Email</Typography> <VStack gap={3}>
<TextField <TextInput
fullWidth label="Email"
placeholder="Enter your email address" isRequired
size="lg"
type="email"
placeholder="admin@doormile.com"
value={auth} value={auth}
onChange={(e) => setAuth(e.target.value)} onChange={setAuth}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
sx={{ startIcon={<Mail size={16} />}
'& .MuiOutlinedInput-root': { hasAutoFocus
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> <div style={{ position: 'relative' }}>
<Typography variant="subtitle2" sx={{ mb: 0.75, color: '#334155', fontWeight: 600 }}>Password</Typography> <TextInput
<TextField label="Password"
fullWidth isRequired
size="lg"
type={show ? 'text' : 'password'} type={show ? 'text' : 'password'}
placeholder="Enter your password" placeholder="Enter your password"
value={pwd} value={pwd}
onChange={(e) => setPwd(e.target.value)} onChange={setPwd}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
sx={{ startIcon={<Lock size={16} />}
'& .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> <button
<Stack direction="row" justifyContent="space-between" alignItems="center"> type="button"
<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>} /> onClick={() => setShow((s) => !s)}
<Link href="#" underline="hover" variant="body2" color="primary" sx={{ fontWeight: 600 }}>Forgot password?</Link> aria-label={show ? 'Hide password' : 'Show password'}
</Stack> style={{
position: 'absolute',
{error && <Typography variant="body2" color="error" textAlign="center" sx={{ fontWeight: 600 }}>{error}</Typography>} right: '10px',
bottom: 0,
<Button height: '36px',
fullWidth display: 'flex',
size="large" alignItems: 'center',
variant="contained" justifyContent: 'center',
onClick={handleLogin} background: 'none',
sx={{ border: 'none',
mt: 2, cursor: 'pointer',
py: 1.5, color: '#64748b',
borderRadius: 2, padding: 0,
fontSize: '1.05rem', zIndex: 10
fontWeight: 700,
textTransform: 'none',
boxShadow: '0 8px 16px rgba(192, 18, 39, 0.25)',
'&:hover': {
boxShadow: '0 12px 20px rgba(192, 18, 39, 0.35)',
},
transition: 'box-shadow 0.2s ease'
}} }}
> >
Sign In {show ? <EyeOff size={16} /> : <Eye size={16} />}
</Button> </button>
</Stack> </div>
</Card> </VStack>
<Box sx={{ position: 'absolute', bottom: 20, zIndex: 2 }}> <HStack hAlign="between" vAlign="center">
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.8)', fontSize: '0.8rem' }}> <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. © {new Date().getFullYear()} Doormile Technologies. All rights reserved.
</Typography> </Text>
</Box> </div>
</Box> </div>
); );
} }

View File

@@ -1,21 +1,24 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { import { Plus, Search, Trash2 } from 'lucide-react';
Card, Box, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, import { Button } from '@astryxdesign/core/Button';
Typography, TextField, Button, Dialog, DialogTitle, DialogContent, DialogActions, import { Card } from '@astryxdesign/core/Card';
Grid, IconButton, CircularProgress, Chip, MenuItem, InputAdornment, TablePagination, Autocomplete, Checkbox, FormControlLabel, Tabs, Tab, Snackbar, Alert import { Table, proportional, pixel } from '@astryxdesign/core/Table';
} from '@mui/material'; import { Badge } from '@astryxdesign/core/Badge';
import AddIcon from '@mui/icons-material/Add'; import { Text } from '@astryxdesign/core/Text';
import SearchIcon from '@mui/icons-material/Search'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import CloseIcon from '@mui/icons-material/Close'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import { FormLayout } from '@astryxdesign/core/FormLayout';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'; import { Field } from '@astryxdesign/core/Field';
import PhoneIphoneOutlinedIcon from '@mui/icons-material/PhoneIphoneOutlined'; import { TextInput } from '@astryxdesign/core/TextInput';
import DesktopWindowsOutlinedIcon from '@mui/icons-material/DesktopWindowsOutlined'; import { NumberInput } from '@astryxdesign/core/NumberInput';
import PendingActionsOutlinedIcon from '@mui/icons-material/PendingActionsOutlined'; import { Selector } from '@astryxdesign/core/Selector';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import StatCard from '@/components/StatCard';
import EmptyState from '@/components/EmptyState'; 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 { fetchClients } from '@/utils/apiClient';
import ClientFormDialog from '../tenants/ClientFormDialog'; import ClientFormDialog from '../tenants/ClientFormDialog';
@@ -122,11 +125,13 @@ function BookingFormDialog({ open, onClose, onSave, clients, surveys }) {
} }
}, [open]); }, [open]);
const handleChange = (field) => (e) => setFormData(prev => ({ ...prev, [field]: e.target.value })); const set = (field) => (val) => {
setFormData(prev => ({ ...prev, [field]: val }));
};
const handleParcelChange = (index, field) => (e) => { const handleParcelChange = (index, field) => (val) => {
const newParcels = [...formData.parcels]; const newParcels = [...formData.parcels];
newParcels[index] = { ...newParcels[index], [field]: e.target.value }; newParcels[index] = { ...newParcels[index], [field]: val };
setFormData(prev => ({ ...prev, parcels: newParcels })); setFormData(prev => ({ ...prev, parcels: newParcels }));
}; };
@@ -202,214 +207,219 @@ function BookingFormDialog({ open, onClose, onSave, clients, surveys }) {
const uniqueProviders = Array.from(new Set(surveys.map(s => s.company).filter(Boolean))); const uniqueProviders = Array.from(new Set(surveys.map(s => s.company).filter(Boolean)));
const providerLocations = surveys.filter(s => s.company === formData.providercompany).map(s => s.area || s.address).filter(Boolean); const providerLocations = surveys.filter(s => s.company === formData.providercompany).map(s => s.area || s.address).filter(Boolean);
const num = (v) => (v === '' || v == null ? undefined : Number(v));
const setNum = (field) => (v) => set(field)(v == null ? '' : v);
return ( return (
<> <>
<Snackbar <Dialog isOpen={open} onOpenChange={(o) => { if (!o) onClose(); }} width={800} maxHeight="90dvh" purpose="form">
open={!!error} <Layout
autoHideDuration={6000} header={<DialogHeader title="Create New Booking" onOpenChange={(o) => { if (!o) onClose(); }} />}
onClose={() => setError(null)} content={
anchorOrigin={{ vertical: 'top', horizontal: 'right' }} <LayoutContent>
sx={{ zIndex: 9999 }} <FormLayout>
> {error && (
<Alert onClose={() => setError(null)} severity="error" variant="filled" sx={{ width: '100%', boxShadow: 3 }}> <div style={{ padding: '12px 16px', backgroundColor: '#fef2f2', border: '1px solid #fca5a5', borderRadius: '8px', color: '#b91c1c', fontSize: '0.875rem' }}>
{error} {error}
</Alert> </div>
</Snackbar> )}
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="md" fullWidth> {/* Customer Details */}
<DialogTitle sx={{ fontWeight: 800 }}>Create New Booking</DialogTitle> <div>
<DialogContent dividers> <div style={{ fontWeight: 700, marginBottom: '8px', color: '#0A1317', fontSize: '0.9rem', textTransform: 'uppercase' }}>Customer Details</div>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'primary.main' }}>Customer Details</Typography> <div style={{ display: 'flex', gap: '16px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<Grid container spacing={2} sx={{ mb: 3 }}> <div style={{ flexGrow: 1, minWidth: '200px' }}>
<Grid item xs={12}> <Field label="Select Client" inputID="booking-client-input">
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}> <input
<Autocomplete id="booking-client-input"
options={clients} list="booking-clients"
sx={{ flexGrow: 1 }} onChange={(e) => {
getOptionLabel={(option) => { const selected = clients.find(c => `${c.first_name || ''} ${c.last_name || ''} - ${c.phone || ''}` === e.target.value);
if (typeof option === 'string') return option; if (selected) {
return `${option.first_name || ''} ${option.last_name || ''} - ${option.phone || ''}`;
}}
onChange={(e, newValue) => {
if (newValue && typeof newValue === 'object') {
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
customer_name: `${newValue.first_name || ''} ${newValue.last_name || ''}`.trim(), customer_name: `${selected.first_name || ''} ${selected.last_name || ''}`.trim(),
customer_phone: newValue.phone || '' customer_phone: selected.phone || ''
})); }));
} else { } else {
setFormData(prev => ({ ...prev, customer_name: '', customer_phone: '' })); setFormData(prev => ({ ...prev, customer_name: '', customer_phone: '' }));
} }
}} }}
renderInput={(params) => (
<TextField {...params} label="Select Existing Client" size="small" />
)}
/> />
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setClientDialogOpen(true)} sx={{ whiteSpace: 'nowrap' }}> <datalist id="booking-clients">
New Client {clients.map((c) => (
</Button> <option key={c.id} value={`${c.first_name || ''} ${c.last_name || ''} - ${c.phone || ''}`} />
</Box> ))}
</Grid> </datalist>
</Grid> </Field>
</div>
<Button variant="secondary" icon={<Plus size={14} />} onClick={() => setClientDialogOpen(true)} label="New Client" />
</div>
</div>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'primary.main' }}>Assigned Provider (From Survey)</Typography> {/* Assigned Provider */}
<Grid container spacing={2} sx={{ mb: 3 }}> <div>
<Grid item xs={12} sm={6}> <div style={{ fontWeight: 700, marginBottom: '8px', color: '#0A1317', fontSize: '0.9rem', textTransform: 'uppercase' }}>Assigned Provider (From Survey)</div>
<Autocomplete <FormLayout direction="horizontal">
<Selector
label="Provider Name" placeholder="Select Provider" hasClear
options={uniqueProviders} options={uniqueProviders}
value={formData.providercompany} value={formData.providercompany}
onChange={(e, val) => setFormData(prev => ({ ...prev, providercompany: val || '', providerlocation: '' }))} onChange={(v) => setFormData(prev => ({ ...prev, providercompany: v ?? '', providerlocation: '' }))}
renderInput={(params) => <TextField {...params} label="Provider Name" size="small" />}
/> />
</Grid> <Selector
<Grid item xs={12} sm={6}> label="Provider Location / Sub Hub" placeholder="Select Location" hasClear
<Autocomplete
options={providerLocations} options={providerLocations}
value={formData.providerlocation} value={formData.providerlocation}
onChange={(e, val) => setFormData(prev => ({ ...prev, providerlocation: val || '' }))} onChange={(v) => set('providerlocation')(v ?? '')}
renderInput={(params) => <TextField {...params} label="Provider Location / Sub Hub" size="small" />} isDisabled={!formData.providercompany}
disabled={!formData.providercompany}
/> />
</Grid> </FormLayout>
</Grid> </div>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'primary.main' }}>Pickup Location</Typography> {/* Pickup Location */}
<Grid container spacing={2} sx={{ mb: 3 }}> <div>
<Grid item xs={12} sm={8}> <div style={{ fontWeight: 700, marginBottom: '8px', color: '#0A1317', fontSize: '0.9rem', textTransform: 'uppercase' }}>Pickup Location</div>
<TextField fullWidth label="Pickup Address *" value={formData.pickupaddress} onChange={handleChange('pickupaddress')} size="small" /> <FormLayout direction="horizontal">
</Grid> <TextInput label="Pickup Address" isRequired value={formData.pickupaddress} onChange={set('pickupaddress')} />
<Grid item xs={12} sm={4}> <TextInput label="Pickup Pincode" isRequired value={formData.pickuppincode} onChange={set('pickuppincode')} />
<TextField fullWidth label="Pickup Pincode *" value={formData.pickuppincode} onChange={handleChange('pickuppincode')} size="small" /> </FormLayout>
</Grid> </div>
</Grid>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'primary.main' }}>Delivery Destination</Typography> {/* Delivery Destination */}
<Grid container spacing={2} sx={{ mb: 3 }}> <div>
<Grid item xs={12} sm={12}> <div style={{ fontWeight: 700, marginBottom: '8px', color: '#0A1317', fontSize: '0.9rem', textTransform: 'uppercase' }}>Delivery Destination</div>
<TextField fullWidth label="Delivery Address" value={formData.deliveryaddress} onChange={handleChange('deliveryaddress')} size="small" /> <FormLayout>
</Grid> <TextInput label="Delivery Address" value={formData.deliveryaddress} onChange={set('deliveryaddress')} />
<Grid item xs={12} sm={8}> <FormLayout direction="horizontal">
<Autocomplete <Field label="City" inputID="delivery-city-input">
freeSolo <input id="delivery-city-input" list="delivery-cities" value={formData.deliverycity} onChange={(e) => set('deliverycity')(e.target.value)} />
options={CITIES} <datalist id="delivery-cities">
value={formData.deliverycity} {CITIES.map((c) => <option key={c} value={c} />)}
onChange={(e, val) => setFormData(prev => ({ ...prev, deliverycity: val || '' }))} </datalist>
onInputChange={(e, val) => setFormData(prev => ({ ...prev, deliverycity: val || '' }))} </Field>
renderInput={(params) => <TextField {...params} label="Search City" size="small" />} <TextInput label="Delivery Pincode" value={formData.deliverypincode} onChange={set('deliverypincode')} />
/> </FormLayout>
</Grid> </FormLayout>
<Grid item xs={12} sm={4}> </div>
<TextField fullWidth label="Delivery Pincode" value={formData.deliverypincode} onChange={handleChange('deliverypincode')} size="small" />
</Grid>
</Grid>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}> {/* Parcel Details */}
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'primary.main' }}>Parcel Details</Typography> <div>
<Button size="small" startIcon={<AddIcon />} onClick={addParcel}>Add Parcel</Button> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
</Box> <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) => ( {formData.parcels.map((parcel, idx) => (
<Grid container spacing={2} key={idx} sx={{ mb: 2, alignItems: 'center' }}> <div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', marginBottom: '12px', flexWrap: 'wrap' }} key={idx}>
<Grid item xs={12} sm={3}> <div style={{ flex: '1 1 150px' }}>
<TextField fullWidth label="Category (e.g. Box)" value={parcel.itemcategory} onChange={handleParcelChange(idx, 'itemcategory')} size="small" /> <TextInput label="Category" isLabelHidden placeholder="Category (e.g. Box)" value={parcel.itemcategory} onChange={handleParcelChange(idx, 'itemcategory')} />
</Grid> </div>
<Grid item xs={6} sm={2}> <div style={{ width: '90px' }}>
<TextField fullWidth label="Weight (kg)" type="number" value={parcel.weight} onChange={handleParcelChange(idx, 'weight')} size="small" /> <NumberInput label="Weight (kg)" isLabelHidden placeholder="Wt (kg)" hasClear value={num(parcel.weight)} onChange={(v) => handleParcelChange(idx, 'weight')(v == null ? '' : v)} />
</Grid> </div>
<Grid item xs={6} sm={2}> <div style={{ width: '90px' }}>
<TextField fullWidth label="Length (cm)" type="number" value={parcel.length} onChange={handleParcelChange(idx, 'length')} size="small" /> <NumberInput label="Length (cm)" isLabelHidden placeholder="L (cm)" hasClear value={num(parcel.length)} onChange={(v) => handleParcelChange(idx, 'length')(v == null ? '' : v)} />
</Grid> </div>
<Grid item xs={6} sm={2}> <div style={{ width: '90px' }}>
<TextField fullWidth label="Width (cm)" type="number" value={parcel.width} onChange={handleParcelChange(idx, 'width')} size="small" /> <NumberInput label="Width (cm)" isLabelHidden placeholder="W (cm)" hasClear value={num(parcel.width)} onChange={(v) => handleParcelChange(idx, 'width')(v == null ? '' : v)} />
</Grid> </div>
<Grid item xs={4} sm={2}> <div style={{ width: '90px' }}>
<TextField fullWidth label="Height (cm)" type="number" value={parcel.height} onChange={handleParcelChange(idx, 'height')} size="small" /> <NumberInput label="Height (cm)" isLabelHidden placeholder="H (cm)" hasClear value={num(parcel.height)} onChange={(v) => handleParcelChange(idx, 'height')(v == null ? '' : v)} />
</Grid> </div>
<Grid item xs={2} sm={1}> <button
<IconButton color="error" onClick={() => removeParcel(idx)} disabled={formData.parcels.length === 1}> onClick={() => removeParcel(idx)}
<DeleteOutlineIcon /> disabled={formData.parcels.length === 1}
</IconButton> style={{
</Grid> background: 'none',
</Grid> border: 'none',
cursor: 'pointer',
color: '#dc2626',
display: 'flex',
padding: '9px',
borderRadius: '4px'
}}
className="action-btn"
>
<Trash2 size={16} />
</button>
</div>
))} ))}
</div>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 1 }}> {/* Price & Insurance */}
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'primary.main' }}>Price & Insurance</Typography> <div>
<Button size="small" variant="outlined" onClick={handleCheckPrice}>Check Price Estimate</Button> <div style={{ display: 'flex', gap: '12px', alignItems: 'center', marginBottom: '12px', flexWrap: 'wrap' }}>
</Box> <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 && ( {quoteInfo && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, px: 1, borderLeft: '4px solid #3b82f6', bgcolor: '#eff6ff', py: 1 }}> <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) Estimated Base Price: <strong>{quoteInfo.basequote?.toFixed(2)}</strong> (Chargeable Weight: {quoteInfo.chargeableweight} kg)
</Typography> </div>
)} )}
<Grid container spacing={2} sx={{ mb: 3 }}> <FormLayout direction="horizontal">
<Grid item xs={12} sm={3}> <NumberInput label="Final Price (₹)" hasClear value={num(formData.finalprice)} onChange={setNum('finalprice')} />
<TextField fullWidth label="Final Price (₹)" type="number" value={formData.finalprice} onChange={handleChange('finalprice')} size="small" /> <div>
</Grid> <CheckboxInput
<Grid item xs={12} sm={9}> label="Needs Insurance? (1%)"
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}> value={formData.needsinsurance}
<FormControlLabel onChange={(checked) => {
control={<Checkbox checked={formData.needsinsurance} onChange={(e) => {
const checked = e.target.checked;
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
needsinsurance: checked, needsinsurance: checked,
insuranceamount: checked && prev.declaredvalue ? (parseFloat(prev.declaredvalue) * 0.01).toFixed(2) : prev.insuranceamount insuranceamount: checked && prev.declaredvalue ? (parseFloat(prev.declaredvalue) * 0.01).toFixed(2) : prev.insuranceamount
})); }));
}} />} }}
label="Needs Insurance? (1%)"
sx={{ whiteSpace: 'nowrap' }}
/> />
<TextField </div>
label="Declared Value (₹)" <NumberInput
type="number" label="Declared Value" hasClear value={num(formData.declaredvalue)}
size="small" onChange={(v) => {
sx={{ width: 150 }}
value={formData.declaredvalue}
onChange={(e) => {
const val = e.target.value;
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
declaredvalue: val, declaredvalue: v == null ? '' : v,
insuranceamount: prev.needsinsurance && val ? (parseFloat(val) * 0.01).toFixed(2) : prev.insuranceamount insuranceamount: prev.needsinsurance && v != null ? (v * 0.01).toFixed(2) : prev.insuranceamount
})); }));
}} }}
/> />
<TextField <NumberInput label="Insurance Amount" hasClear value={num(formData.insuranceamount)} onChange={setNum('insuranceamount')} />
label="Insurance Amount (₹)" </FormLayout>
type="number" </div>
size="small"
sx={{ width: 150 }} {/* Additional info */}
value={formData.insuranceamount} <div>
onChange={handleChange('insuranceamount')} <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>
}
/> />
</Box>
</Grid>
</Grid>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1, color: 'primary.main' }}>Additional Information</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField select fullWidth label="Select Speed" value={formData.service_option} onChange={handleChange('service_option')} size="small">
<MenuItem value="Normal">Normal</MenuItem>
<MenuItem value="Fast">Fast Express</MenuItem>
</TextField>
</Grid>
<Grid item xs={12} sm={6}>
<TextField fullWidth label="Notes / Remarks" value={formData.notes} onChange={handleChange('notes')} size="small" />
</Grid>
</Grid>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} disabled={saving}>Cancel</Button>
<Button variant="contained" onClick={handleSave} disabled={saving || !formData.customer_phone} sx={{ bgcolor: 'primary.main', '&:hover': { bgcolor: 'primary.dark' } }}>
{saving ? 'Creating...' : 'Create Booking'}
</Button>
</DialogActions>
</Dialog> </Dialog>
{/* Embedded Client Registration Form */}
{clientDialogOpen && ( {clientDialogOpen && (
<ClientFormDialog <ClientFormDialog
open={clientDialogOpen} open={clientDialogOpen}
@@ -417,7 +427,7 @@ function BookingFormDialog({ open, onClose, onSave, clients, surveys }) {
onClose={() => setClientDialogOpen(false)} onClose={() => setClientDialogOpen(false)}
onSaved={() => { onSaved={() => {
setClientDialogOpen(false); setClientDialogOpen(false);
onSave(); // Refetch clients so they appear in dropdown onSave();
}} }}
/> />
)} )}
@@ -467,114 +477,148 @@ export default function Bookings() {
return matchesSearch && matchesSource; return matchesSearch && matchesSource;
}); });
const paged = displayBookings.slice(page * rpp, page * rpp + rpp); const pagedBookings = displayBookings.slice(page * rpp, page * rpp + rpp);
const stats = { const bookingColumns = useMemo(() => [
total: bookings.length, {
app: bookings.filter((b) => b.bookingsource === 'Customer_App').length, key: 'bookingno',
crm: bookings.filter((b) => b.bookingsource === 'CRM_Console').length, header: <div style={{ paddingLeft: '12px' }}>Booking No</div>,
pending: bookings.filter((b) => b.status === 'Pending_Pickup').length width: proportional(1.5),
}; renderCell: (b) => (
<div style={{ paddingLeft: '12px' }}>
<Text type="body" weight="semibold">{b.bookingno}</Text>
</div>
)
},
{
key: 'deliverycity',
header: 'Delivery City',
width: proportional(1),
renderCell: (b) => {
const city = b.deliverycity || b.deliverypincode;
return city
? <Text type="body">{city}</Text>
: <Text type="supporting" color="disabled">N/A</Text>;
}
},
{
key: 'providercompany',
header: 'Provider',
width: proportional(1),
renderCell: (b) => b.providercompany
? <Badge variant="neutral" label={b.providercompany} />
: <Text type="supporting" color="disabled">N/A</Text>
},
{
key: 'bookingsource',
header: 'Source',
width: pixel(100),
renderCell: (b) => (
<Badge
variant={b.bookingsource === 'CRM_Console' ? 'neutral' : 'red'}
label={b.bookingsource === 'CRM_Console' ? 'CRM' : 'App'}
/>
)
},
{
key: 'price',
header: 'Price',
width: pixel(120),
renderCell: (b) => (
<Text type="body" weight="semibold" hasTabularNumbers>
{b.serviceoptions && b.serviceoptions.length > 0 ? `${b.serviceoptions[0].estimatedprice}` : 'N/A'}
</Text>
)
},
{
key: 'status',
header: 'Status',
width: pixel(150),
renderCell: (b) => <StatusChip status={b.status} />
}
], []);
const sourceCounts = useMemo(() => {
const counts = { All: bookings.length, Customer_App: 0, CRM_Console: 0 };
bookings.forEach((b) => {
if (b.bookingsource === 'CRM_Console') counts.CRM_Console += 1;
else counts.Customer_App += 1;
});
return counts;
}, [bookings]);
return ( return (
<> <div style={{ fontFamily: 'system-ui, sans-serif' }}>
<PageHeader title="Bookings Management" breadcrumbs={[{ label: 'Bookings' }]} /> <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>
}
/>
<Grid container spacing={2.5} sx={{ mb: 3 }}> <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)' }}>
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Bookings" value={stats.total} icon={LocalShippingOutlinedIcon} color="primary" caption="All sources" /></Grid> {/* Tab filters */}
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="App Bookings" value={stats.app} icon={PhoneIphoneOutlinedIcon} color="primary" caption="Via customer app" /></Grid> <div style={{ display: 'flex', padding: '0 24px', borderBottom: '1px solid rgba(5, 54, 89, 0.06)', gap: '24px', overflowX: 'auto' }}>
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="CRM Bookings" value={stats.crm} icon={DesktopWindowsOutlinedIcon} color="primary" caption="Created in console" /></Grid> {[
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Pending Pickup" value={stats.pending} icon={PendingActionsOutlinedIcon} color="primary" caption="Awaiting collection" /></Grid> { label: 'All Bookings', value: 'All' },
</Grid> { label: 'App Bookings', value: 'Customer_App' },
{ label: 'CRM Bookings', value: 'CRM_Console' }
<Card sx={{ overflow: 'hidden' }}> ].map((tab) => (
<Box <div
sx={{ key={tab.value}
px: { xs: 2, sm: 2.5 }, py: 2, borderBottom: 1, borderColor: 'divider', onClick={() => { setSourceFilter(tab.value); setPage(0); }}
display: 'flex', alignItems: 'center', gap: 1.5, style={{
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}66 0%, ${theme.palette.background.paper} 70%)` 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'
}} }}
> >
<Box sx={{ width: 40, height: 40, borderRadius: 2, bgcolor: 'primary.lighter', color: 'primary.main', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}> <TabLabelCount label={tab.label} count={sourceCounts[tab.value] || 0} active={sourceFilter === tab.value} />
<LocalShippingOutlinedIcon fontSize="small" /> </div>
</Box> ))}
<Box> </div>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.2 }}>All Bookings</Typography>
<Typography variant="caption" color="text.secondary">Track and manage every shipment booking</Typography>
</Box>
</Box>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
<TextField
size="small" placeholder="Search bookings…" 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 && (
<Typography variant="body2" color="text.secondary">
{displayBookings.length} {displayBookings.length === 1 ? 'booking' : 'bookings'}
</Typography>
)}
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setFormOpen(true)} sx={{ flex: { xs: 1, md: 'none' } }}>
New Booking
</Button>
</Stack>
<Box sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={sourceFilter} onChange={(e, val) => { setSourceFilter(val); setPage(0); }} aria-label="booking source filter tabs">
<Tab label="All Bookings" value="All" />
<Tab label="App Bookings" value="Customer_App" />
<Tab label="CRM Bookings" value="CRM_Console" />
</Tabs>
</Box>
{loading ? ( {loading ? (
<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>
) : displayBookings.length === 0 ? ( ) : displayBookings.length === 0 ? (
<EmptyState title="No bookings found" caption="Try a different filter or search term." /> <EmptyState title="No bookings found" caption="Try a different filter or search term, or create a new booking." />
) : ( ) : (
<TableContainer> <div style={{ paddingTop: '12px', paddingBottom: '12px' }}>
<Table> <Table
<TableHead> data={pagedBookings}
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', textTransform: 'uppercase', fontSize: '0.72rem', letterSpacing: 0.4 } }}> columns={bookingColumns}
<TableCell>Booking No</TableCell> idKey="bookingid"
<TableCell>Delivery City</TableCell> density="balanced"
<TableCell>Provider</TableCell> dividers="rows"
<TableCell>Source</TableCell> hasHover
<TableCell>Price</TableCell> isStriped
<TableCell>Status</TableCell> textOverflow="truncate"
</TableRow>
</TableHead>
<TableBody>
{paged.map(b => (
<TableRow key={b.bookingid} hover>
<TableCell sx={{ fontWeight: 600 }}>{b.bookingno}</TableCell>
<TableCell>{b.deliverycity || b.deliverypincode || 'N/A'}</TableCell>
<TableCell>
{b.providercompany ? (
<Chip label={b.providercompany} size="small" sx={{ mr: 1 }} />
) : 'N/A'}
</TableCell>
<TableCell>
<Chip label={b.bookingsource === 'CRM_Console' ? 'CRM' : 'App'} size="small" variant="outlined" color={b.bookingsource === 'CRM_Console' ? 'secondary' : 'primary'} />
</TableCell>
<TableCell>
{b.serviceoptions && b.serviceoptions.length > 0 ? `${b.serviceoptions[0].estimatedprice}` : 'N/A'}
</TableCell>
<TableCell>
<Chip label={b.status} size="small" color={b.status === 'Pending_Pickup' ? 'warning' : 'primary'} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<TablePagination
component="div" count={displayBookings.length} page={page} onPageChange={(_, p) => setPage(p)}
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[5, 10, 25]}
/> />
</div>
)}
{!loading && displayBookings.length > 0 && (
<TablePagination page={page} rpp={rpp} total={displayBookings.length} onPageChange={setPage} onRppChange={setRpp} />
)}
</Card> </Card>
<BookingFormDialog <BookingFormDialog
@@ -584,6 +628,31 @@ export default function Bookings() {
clients={clients} clients={clients}
surveys={surveys} 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'
};

View File

@@ -1,5 +1,8 @@
import { useState } from 'react'; 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'; import Logo from '@/components/Logo';
@@ -14,67 +17,73 @@ export default function ComingSoon() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
return ( return (
<Box <div
sx={{ style={{
minHeight: '100vh', minHeight: '100vh',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
p: 3, padding: '24px',
textAlign: 'center' 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 /> <Logo />
<Typography variant="h2" sx={{ fontWeight: 800, color: 'grey.800' }}>Coming Soon</Typography> <Heading level={1} type="display-2">Coming Soon</Heading>
<Typography variant="body1" color="text.secondary">Something new is on its way</Typography> <Text type="large" color="secondary">Something new is on its way</Text>
{/* Countdown */} {/* 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) => ( {COUNTDOWN.map((c, i) => (
<Stack key={c.label} direction="row" spacing={{ xs: 1, sm: 2 }} alignItems="center"> <div key={c.label} style={{ display: 'flex', flexDirection: 'row', gap: '16px', alignItems: 'center' }}>
<Box <div
sx={{ style={{
width: { xs: 64, sm: 84 }, width: '84px',
py: 2, padding: '16px 0',
borderRadius: 2, borderRadius: '12px',
bgcolor: 'primary.lighter', backgroundColor: 'rgba(10, 19, 23, 0.08)',
border: 1, border: '1px solid rgba(10, 19, 23, 0.2)'
borderColor: 'primary.light'
}} }}
> >
<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')} {String(c.value).padStart(2, '0')}
</Typography> </div>
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase', letterSpacing: 1 }}> <div style={{ fontSize: '0.65rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '1px', marginTop: '4px' }}>
{c.label} {c.label}
</Typography> </div>
</Box> </div>
{i < COUNTDOWN.length - 1 && ( {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 */} {/* Subscribe */}
<Box sx={{ width: '100%', maxWidth: 480 }}> <div style={{ width: '100%', maxWidth: '480px', marginTop: '16px' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}> <div style={{ marginBottom: '12px' }}>
<Text type="body" color="secondary">
Be the first to be notified when Doormile launches. Be the first to be notified when Doormile launches.
</Typography> </Text>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}> </div>
<TextField <div style={{ display: 'flex', gap: '12px', flexDirection: 'row', alignItems: 'flex-start', flexWrap: 'wrap' }} className="subscribe-row">
fullWidth <div style={{ flexGrow: 1, minWidth: '200px' }}>
size="small" <TextInput
label="Email address"
isLabelHidden
type="email" type="email"
placeholder="Enter your email" placeholder="Enter your email"
startIcon={<Mail size={16} />}
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={setEmail}
/> />
<Button variant="contained" sx={{ whiteSpace: 'nowrap', px: 3 }}>Notify Me</Button> </div>
</Stack> <Button variant="primary" label="Notify Me" onClick={() => {}} />
</Box> </div>
</Stack> </div>
</Box> </div>
</div>
); );
} }

View File

@@ -1,34 +1,37 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Box, Stack, Typography, Button } from '@mui/material'; import { Button } from '@astryxdesign/core/Button';
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'; import { Text, Heading } from '@astryxdesign/core/Text';
import { Home } from 'lucide-react';
export default function Error404() { export default function Error404() {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<Box <div
sx={{ style={{
minHeight: '100vh', minHeight: '100vh',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
p: 3, padding: '24px',
textAlign: 'center' textAlign: 'center',
backgroundColor: '#f8fafc',
fontFamily: 'system-ui, sans-serif'
}} }}
> >
<Stack spacing={2} alignItems="center"> <div style={{ display: 'flex', flexDirection: 'column', gap: '16px', alignItems: 'center' }}>
<Typography sx={{ fontWeight: 900, fontSize: { xs: '6rem', md: '9rem' }, lineHeight: 1, color: 'primary.main' }}> <span style={{ fontWeight: 900, fontSize: 'clamp(3.5rem, 18vw, 8rem)', lineHeight: 1, color: '#0A1317' }}>
404 404
</Typography> </span>
<Box sx={{ width: 64, height: 4, borderRadius: 2, bgcolor: 'primary.main' }} /> <div style={{ width: '64px', height: '4px', borderRadius: '2px', backgroundColor: '#0A1317' }} />
<Typography variant="h3" sx={{ fontWeight: 700, color: 'grey.800' }}>Page Not Found</Typography> <Heading level={2}>Page Not Found</Heading>
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 460 }}> <div style={{ maxWidth: '460px' }}>
<Text type="body" color="secondary">
The page you are looking for was moved, removed, renamed, or might never exist! The page you are looking for was moved, removed, renamed, or might never exist!
</Typography> </Text>
<Button variant="contained" size="large" startIcon={<HomeOutlinedIcon />} onClick={() => navigate('/dashboard')} sx={{ mt: 1 }}> </div>
Back To Home <Button variant="primary" icon={<Home size={16} />} onClick={() => navigate('/dashboard')} label="Back To Home" />
</Button> </div>
</Stack> </div>
</Box>
); );
} }

View File

@@ -1,34 +1,37 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Box, Stack, Typography, Button } from '@mui/material'; import { Button } from '@astryxdesign/core/Button';
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'; import { Text, Heading } from '@astryxdesign/core/Text';
import { Home } from 'lucide-react';
export default function Error500() { export default function Error500() {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<Box <div
sx={{ style={{
minHeight: '100vh', minHeight: '100vh',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
p: 3, padding: '24px',
textAlign: 'center' textAlign: 'center',
backgroundColor: '#f8fafc',
fontFamily: 'system-ui, sans-serif'
}} }}
> >
<Stack spacing={2} alignItems="center"> <div style={{ display: 'flex', flexDirection: 'column', gap: '16px', alignItems: 'center' }}>
<Typography sx={{ fontWeight: 900, fontSize: { xs: '6rem', md: '9rem' }, lineHeight: 1, color: 'primary.main' }}> <span style={{ fontWeight: 900, fontSize: 'clamp(3.5rem, 18vw, 8rem)', lineHeight: 1, color: '#0A1317' }}>
500 500
</Typography> </span>
<Box sx={{ width: 64, height: 4, borderRadius: 2, bgcolor: 'primary.main' }} /> <div style={{ width: '64px', height: '4px', borderRadius: '2px', backgroundColor: '#0A1317' }} />
<Typography variant="h3" sx={{ fontWeight: 700, color: 'grey.800' }}>Internal Server Error</Typography> <Heading level={2}>Internal Server Error</Heading>
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 460 }}> <div style={{ maxWidth: '460px' }}>
<Text type="body" color="secondary">
Server error 500. We are fixing the problem. Please try again at a later stage. Server error 500. We are fixing the problem. Please try again at a later stage.
</Typography> </Text>
<Button variant="contained" size="large" startIcon={<HomeOutlinedIcon />} onClick={() => navigate('/dashboard')} sx={{ mt: 1 }}> </div>
Back To Home <Button variant="primary" icon={<Home size={16} />} onClick={() => navigate('/dashboard')} label="Back To Home" />
</Button> </div>
</Stack> </div>
</Box>
); );
} }

View File

@@ -1,45 +1,47 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Box, Stack, Typography, Button } from '@mui/material'; import { Button } from '@astryxdesign/core/Button';
import ConstructionOutlinedIcon from '@mui/icons-material/ConstructionOutlined'; import { Text, Heading } from '@astryxdesign/core/Text';
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'; import { Construction, Home } from 'lucide-react';
export default function UnderConstruction() { export default function UnderConstruction() {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<Box <div
sx={{ style={{
minHeight: '100vh', minHeight: '100vh',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
p: 3, padding: '24px',
textAlign: 'center' textAlign: 'center',
backgroundColor: '#f8fafc',
fontFamily: 'system-ui, sans-serif'
}} }}
> >
<Stack spacing={2.5} alignItems="center"> <div style={{ display: 'flex', flexDirection: 'column', gap: '20px', alignItems: 'center' }}>
<Box <div
sx={{ style={{
width: 110, width: '110px',
height: 110, height: '110px',
borderRadius: '50%', borderRadius: '50%',
bgcolor: 'primary.lighter', backgroundColor: 'rgba(10, 19, 23, 0.08)',
color: 'primary.main', color: '#0A1317',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center' justifyContent: 'center'
}} }}
> >
<ConstructionOutlinedIcon sx={{ fontSize: 56 }} /> <Construction size={56} />
</Box> </div>
<Typography variant="h3" sx={{ fontWeight: 700, color: 'grey.800' }}>Under Construction</Typography> <Heading level={2}>Under Construction</Heading>
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 460 }}> <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. Hey! Please check out this site later. We are doing some maintenance on it right now.
</Typography> </Text>
<Button variant="contained" size="large" startIcon={<HomeOutlinedIcon />} onClick={() => navigate('/dashboard')} sx={{ mt: 1 }}> </div>
Back To Home <Button variant="primary" icon={<Home size={16} />} onClick={() => navigate('/dashboard')} label="Back To Home" />
</Button> </div>
</Stack> </div>
</Box>
); );
} }

View File

@@ -1,22 +1,22 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { import { Plus, Search, Edit, ChevronDown, ChevronUp, MapPin, LayoutGrid } from 'lucide-react';
Card, Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, import { Button } from '@astryxdesign/core/Button';
Typography, TextField, InputAdornment, CircularProgress, MenuItem, import { Card } from '@astryxdesign/core/Card';
Select, FormControl, OutlinedInput, Avatar, Chip, Stack, Collapse, Button, import { Table, proportional, pixel } from '@astryxdesign/core/Table';
Dialog, DialogTitle, DialogContent, DialogActions, IconButton, Grid, Tooltip, Alert, Snackbar import { Badge } from '@astryxdesign/core/Badge';
} from '@mui/material'; import { Text, Heading } from '@astryxdesign/core/Text';
import AddIcon from '@mui/icons-material/Add'; import { IconButton } from '@astryxdesign/core/IconButton';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import { TextInput } from '@astryxdesign/core/TextInput';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; import { Selector } from '@astryxdesign/core/Selector';
import CloseIcon from '@mui/icons-material/Close'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import SearchIcon from '@mui/icons-material/Search'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'; import { FormLayout } from '@astryxdesign/core/FormLayout';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'; import { Banner } from '@astryxdesign/core/Banner';
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined'; import { Item } from '@astryxdesign/core/Item';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import { Icon } from '@astryxdesign/core/Icon';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import PriceCheckOutlinedIcon from '@mui/icons-material/PriceCheckOutlined';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import EmptyState from '@/components/EmptyState';
import { fetchUsers } from '@/utils/apiClient'; import { fetchUsers } from '@/utils/apiClient';
const API_BASE = import.meta.env.VITE_API_BASE || 'https://api.doormile.com/api/v1'; const API_BASE = import.meta.env.VITE_API_BASE || 'https://api.doormile.com/api/v1';
@@ -61,8 +61,6 @@ async function apiSavePricing(data) {
const ZONES = ['Local / Same', 'Within Tamilnadu', 'Interstate India']; const ZONES = ['Local / Same', 'Within Tamilnadu', 'Interstate India'];
const WEIGHT_SLABS = ['< 500g', '500g - 1kg', '1kg - 2kg', '2kg - 5kg', '> 5kg']; const WEIGHT_SLABS = ['< 500g', '500g - 1kg', '1kg - 2kg', '2kg - 5kg', '> 5kg'];
// isCell=true → editing a specific cell (company/slab/zone locked, only rate editable)
// isCell=false → "Add Rate" button (all fields editable)
function PricingFormDialog({ open, onClose, onSave, initialData, isCell = false, pricingList = [], providerColumns = [], providerUsesServiceType = false }) { function PricingFormDialog({ open, onClose, onSave, initialData, isCell = false, pricingList = [], providerColumns = [], providerUsesServiceType = false }) {
const [formData, setFormData] = useState({}); const [formData, setFormData] = useState({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -75,9 +73,6 @@ function PricingFormDialog({ open, onClose, onSave, initialData, isCell = false,
} }
}, [open, initialData]); }, [open, initialData]);
const set = (field) => (e) => setFormData(prev => ({ ...prev, [field]: e.target.value }));
// Column key: zone if available, otherwise service_type (e.g. DTDC)
const colKey = (p) => ((p.zone || '').trim() || (p.service_type || '').trim()); const colKey = (p) => ((p.zone || '').trim() || (p.service_type || '').trim());
const findExistingRecord = (data) => { const findExistingRecord = (data) => {
@@ -104,7 +99,6 @@ function PricingFormDialog({ open, onClose, onSave, initialData, isCell = false,
setError(null); setError(null);
try { try {
let dataToSave = { ...formData }; let dataToSave = { ...formData };
// Route the column value to the right field based on provider type
if (!isCell) { if (!isCell) {
dataToSave = { dataToSave = {
...dataToSave, ...dataToSave,
@@ -127,85 +121,93 @@ function PricingFormDialog({ open, onClose, onSave, initialData, isCell = false,
} }
}; };
return ( if (!open) return null;
<>
<Snackbar const zoneOptions = providerColumns.length > 0 ? providerColumns : ZONES;
open={!!error}
autoHideDuration={6000} return (
onClose={() => setError(null)} <Dialog isOpen={open} onOpenChange={(o) => { if (!o) onClose(); }} width={440} purpose="form">
anchorOrigin={{ vertical: 'top', horizontal: 'right' }} <Layout
sx={{ zIndex: 9999 }} header={
> <DialogHeader
<Alert onClose={() => setError(null)} severity="error" variant="filled" sx={{ width: '100%', boxShadow: 3 }}> title={initialData?.id ? 'Edit Rate' : (isCell ? 'Add Rate' : 'Add New Rate')}
{error} onOpenChange={(o) => { if (!o) onClose(); }}
</Alert> />
</Snackbar> }
content={
<LayoutContent>
<FormLayout>
{error && <Banner status="error" title={error} />}
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 800, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{isCell ? (initialData?.id ? 'Edit Rate' : 'Add Rate') : (initialData?.id ? 'Edit Rate' : 'Add New Rate')}
<IconButton size="small" onClick={onClose} disabled={saving}><CloseIcon fontSize="small" /></IconButton>
</DialogTitle>
<DialogContent dividers>
<Grid container spacing={2}>
{isCell ? ( {isCell ? (
<Grid item xs={12}> <Card style={{ padding: '12px 16px', backgroundColor: 'rgba(10, 19, 23, 0.04)', border: '1px solid rgba(10, 19, 23, 0.15)' }}>
<Box sx={{ p: 1.5, borderRadius: 2, bgcolor: 'primary.lighter', border: '1px solid', borderColor: 'primary.light', mb: 0.5 }}> <Text type="label" color="secondary">Updating rate for</Text>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'grey.500', letterSpacing: 0.5, display: 'block', mb: 0.5 }}>UPDATING RATE FOR</Typography> <div style={{ marginTop: '4px' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'grey.900' }}>{formData.company}</Typography> <Heading level={5}>{formData.company}</Heading>
<Typography variant="body2" sx={{ color: 'grey.600', mt: 0.25 }}>{formData.weight_slab} · {formData.zone || formData.service_type}</Typography> </div>
</Box> <div style={{ marginTop: '2px' }}>
</Grid> <Text type="supporting" color="secondary">{formData.weight_slab} · {formData.zone || formData.service_type}</Text>
</div>
</Card>
) : ( ) : (
<> <>
<Grid item xs={12}> <TextInput
<TextField fullWidth label="Company *" value={formData.company || ''} onChange={set('company')} size="small" /> label="Company"
</Grid> isRequired
<Grid item xs={12}> value={formData.company || ''}
<TextField select fullWidth label="Weight Slab *" value={formData.weight_slab || ''} onChange={set('weight_slab')} size="small"> onChange={(v) => setFormData((prev) => ({ ...prev, company: v }))}
{WEIGHT_SLABS.map(s => <MenuItem key={s} value={s}>{s}</MenuItem>)} />
</TextField> <Selector
</Grid> label="Weight Slab"
<Grid item xs={12}> isRequired
<TextField placeholder="Select Weight Slab"
select fullWidth options={WEIGHT_SLABS}
label={providerUsesServiceType ? 'Service Type *' : 'Zone / Service Type *'} 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 || ''} value={formData.zone || formData.service_type || ''}
onChange={(e) => setFormData(prev => ({ onChange={(v) => setFormData((prev) => ({
...prev, ...prev,
zone: providerUsesServiceType ? '' : e.target.value, zone: providerUsesServiceType ? '' : (v || ''),
service_type: providerUsesServiceType ? e.target.value : '' service_type: providerUsesServiceType ? (v || '') : ''
}))} }))}
size="small" />
>
{(providerColumns.length > 0 ? providerColumns : ZONES).map(z => (
<MenuItem key={z} value={z}>{z}</MenuItem>
))}
</TextField>
</Grid>
</> </>
)} )}
<Grid item xs={12}>
<TextField <TextInput
fullWidth label="Rate (₹)"
label="Rate (₹) *" isRequired
value={formData.rate || ''} hasClear
onChange={set('rate')} hasAutoFocus
size="small"
placeholder="e.g. 25 or 25-35" placeholder="e.g. 25 or 25-35"
autoFocus 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>
}
/> />
</Grid>
</Grid>
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onClose} disabled={saving} color="inherit">Cancel</Button>
<Button onClick={handleSave} disabled={saving} variant="contained">
{saving ? 'Saving...' : 'Save Rate'}
</Button>
</DialogActions>
</Dialog> </Dialog>
</>
); );
} }
@@ -224,9 +226,9 @@ export default function Pricing() {
const [selectedProvider, setSelectedProvider] = useState(''); const [selectedProvider, setSelectedProvider] = useState('');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [locationsOpen, setLocationsOpen] = useState(false); const [locationsOpen, setLocationsOpen] = useState(false);
const [locationSearch, setLocationSearch] = useState('');
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState(null); const [editingRecord, setEditingRecord] = useState(null);
const [isEditMode, setIsEditMode] = useState(false);
const [isCellEdit, setIsCellEdit] = useState(false); const [isCellEdit, setIsCellEdit] = useState(false);
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
@@ -254,7 +256,6 @@ export default function Pricing() {
return loadData(); return loadData();
}, []); }, []);
// Ensure absolutely unique companies and default selection
const companies = [...new Set(pricingList.map(p => p.company).filter(Boolean))]; const companies = [...new Set(pricingList.map(p => p.company).filter(Boolean))];
useEffect(() => { useEffect(() => {
if (!selectedProvider && companies.length > 0) { if (!selectedProvider && companies.length > 0) {
@@ -263,19 +264,23 @@ export default function Pricing() {
}, [companies, selectedProvider]); }, [companies, selectedProvider]);
const providerRates = pricingList.filter(p => p.company === selectedProvider); const providerRates = pricingList.filter(p => p.company === selectedProvider);
// Normalize: lowercase, trim edges, collapse internal spaces
const normCompany = (s) => (s || '').toLowerCase().trim().replace(/\s+/g, ' '); const normCompany = (s) => (s || '').toLowerCase().trim().replace(/\s+/g, ' ');
const normProvider = normCompany(selectedProvider); const normProvider = normCompany(selectedProvider);
const providerSurveys = surveysList.filter(p => { const providerSurveys = surveysList.filter(p => {
const pn = normCompany(p.company); const pn = normCompany(p.company);
// Exact after normalising, or one name starts with the other (handles "Pvt Ltd" suffixes)
return pn === normProvider || pn.startsWith(normProvider) || normProvider.startsWith(pn); return pn === normProvider || pn.startsWith(normProvider) || normProvider.startsWith(pn);
}); });
// Sort by ID desc so most recent record wins when duplicates exist 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 sortedProviderRates = [...providerRates].sort((a, b) => (b.id || 0) - (a.id || 0));
// Extract numeric weight from a slab string for sorting rows logically
const slabSortWeight = (slab) => { const slabSortWeight = (slab) => {
const s = (slab || '').toLowerCase().replace(/\s+/g, ''); const s = (slab || '').toLowerCase().replace(/\s+/g, '');
const nums = s.match(/[\d.]+/g); const nums = s.match(/[\d.]+/g);
@@ -287,15 +292,23 @@ export default function Pricing() {
return grams; return grams;
}; };
// Use zone if available, fall back to service_type (e.g. DTDC has service_type instead of zone) const normalizeZone = (z) => {
const getColKey = (r) => ((r.zone || '').trim() || (r.service_type || '').trim()); 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 && const providerUsesServiceType = sortedProviderRates.length > 0 &&
sortedProviderRates.every(r => !(r.zone || '').trim()); sortedProviderRates.every(r => !(r.zone || '').trim());
// Dynamic columns — whichever field the provider uses for differentiation
const providerZones = [...new Set(sortedProviderRates.map(getColKey).filter(Boolean))].sort(); const providerZones = [...new Set(sortedProviderRates.map(getColKey).filter(Boolean))].sort();
// Dynamic rows: every unique weight slab, sorted by actual weight
const allProviderSlabs = [...new Set(sortedProviderRates.map(r => (r.weight_slab || '').trim()).filter(Boolean))] const allProviderSlabs = [...new Set(sortedProviderRates.map(r => (r.weight_slab || '').trim()).filter(Boolean))]
.sort((a, b) => slabSortWeight(a) - slabSortWeight(b)); .sort((a, b) => slabSortWeight(a) - slabSortWeight(b));
@@ -303,221 +316,21 @@ export default function Pricing() {
? allProviderSlabs.filter(s => s.toLowerCase().includes(searchQuery.toLowerCase())) ? allProviderSlabs.filter(s => s.toLowerCase().includes(searchQuery.toLowerCase()))
: allProviderSlabs; : allProviderSlabs;
return ( const matrixData = useMemo(
<> () => displaySlabs.map((slab) => ({
<PageHeader slab,
title="Logistics Pricing Board" rates: Object.fromEntries(
breadcrumbs={[{ label: 'Pricing Overview' }]} providerZones.map((zone) => [
/> zone,
sortedProviderRates.find(r => (r.weight_slab || '').trim() === slab && getColKey(r) === zone) || null
])
)
})),
[displaySlabs, providerZones, sortedProviderRates]
);
<Stack spacing={3}> const matrixColumns = useMemo(() => {
const openCellEdit = (slab, zone, match) => {
{/* Control Panel */}
<Card sx={{ overflow: 'hidden' }}>
<Box
sx={{
px: { xs: 2, 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' }}>
<PriceCheckOutlinedIcon fontSize="small" />
</Box>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.2 }}>Pricing Matrix</Typography>
<Typography variant="caption" color="text.secondary">Manage carrier rates by weight slab and zone</Typography>
</Box>
</Box>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
<TextField
size="small"
placeholder="Search weight slabs…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
sx={{ width: { xs: '100%', md: 300 } }}
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
/>
<Box sx={{ flexGrow: 1 }} />
{/* Active Provider Dropdown */}
<FormControl size="small" sx={{ minWidth: 220 }}>
<Select
value={selectedProvider || ''}
onChange={(e) => setSelectedProvider(e.target.value)}
displayEmpty
size="small"
IconComponent={StorefrontOutlinedIcon}
input={<OutlinedInput sx={{ fontWeight: 700 }} />}
>
{companies.length === 0 ? (
<MenuItem value="" disabled>Loading Providers...</MenuItem>
) : (
companies.map(c => (
<MenuItem key={c} value={c} sx={{ fontWeight: 600 }}>{c}</MenuItem>
))
)}
</Select>
</FormControl>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setEditingRecord({ company: selectedProvider, weight_slab: '', zone: '', rate: '' }); setIsCellEdit(false); setFormOpen(true); }} sx={{ whiteSpace: 'nowrap' }}>
Add Rate
</Button>
<Button
variant={isEditMode ? 'contained' : 'outlined'}
color={isEditMode ? 'inherit' : 'primary'}
startIcon={<EditOutlinedIcon />}
onClick={() => setIsEditMode(!isEditMode)}
sx={{ whiteSpace: 'nowrap', ...(isEditMode && { bgcolor: 'grey.800', color: 'white', '&:hover': { bgcolor: 'grey.900' } }) }}
>
{isEditMode ? 'Done' : 'Edit'}
</Button>
</Stack>
</Card>
{/* Minimalist Collapsible Provider Profile Widget */}
{selectedProvider && providerSurveys.length > 0 && (
<Card
elevation={0}
sx={{
border: '1px solid',
borderColor: locationsOpen ? 'primary.main' : 'grey.200',
transition: 'border-color 0.3s ease, box-shadow 0.3s ease',
'&:hover': {
borderColor: 'primary.main',
boxShadow: '0 8px 30px rgba(192, 18, 39, 0.08)',
}
}}
>
<Stack
direction={{ xs: 'column', md: 'row' }} spacing={3} sx={{ p: { xs: 2, sm: 2.5 }, cursor: 'pointer', userSelect: 'none' }}
alignItems={{ xs: 'flex-start', md: 'center' }} justifyContent="space-between"
onClick={() => setLocationsOpen(!locationsOpen)}
>
{/* Avatar & Title */}
<Stack direction="row" spacing={2} alignItems="center" sx={{ minWidth: 260 }}>
<Avatar sx={{ width: 42, height: 42, bgcolor: 'primary.lighter', color: 'primary.main', fontWeight: 800, borderRadius: 2 }}>
{(selectedProvider || 'A')[0].toUpperCase()}
</Avatar>
<Box>
<Typography variant="overline" sx={{ color: 'grey.500', fontWeight: 700, letterSpacing: 1.2 }}>LOGISTICS PARTNER</Typography>
<Typography variant="h5" sx={{ fontWeight: 800, color: 'grey.900', lineHeight: 1.1, mt: 0.25 }}>{selectedProvider}</Typography>
<Typography variant="body2" sx={{ color: 'grey.600', mt: 1, fontWeight: 500, display: 'flex', alignItems: 'center' }}>
<PlaceOutlinedIcon sx={{ fontSize: 16, mr: 0.5, color: 'primary.main' }} />
{providerSurveys.length} Registered Location{providerSurveys.length !== 1 && 's'}
</Typography>
</Box>
</Stack>
{/* Badges & Action */}
<Stack direction="row" spacing={3} alignItems="center">
<Stack direction="row" spacing={1} sx={{ display: { xs: 'none', md: 'flex' } }}>
<Chip size="small" label="First Mile" sx={{ bgcolor: 'primary.lighter', color: 'primary.main', fontWeight: 700 }} />
<Chip size="small" label="Mid Mile" sx={{ bgcolor: 'primary.lighter', color: 'primary.main', fontWeight: 700 }} />
<Chip size="small" label="Last Mile" sx={{ bgcolor: 'primary.lighter', color: 'primary.main', fontWeight: 700 }} />
</Stack>
<Button
onClick={(e) => { e.stopPropagation(); setLocationsOpen(!locationsOpen); }}
sx={{
borderRadius: 2, py: 1, px: 3, fontWeight: 700,
color: locationsOpen ? 'primary.main' : 'grey.700',
bgcolor: locationsOpen ? 'primary.lighter' : 'grey.100',
'&:hover': { bgcolor: 'primary.lighter', color: 'primary.main' }
}}
endIcon={locationsOpen ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
>
{locationsOpen ? 'Hide Coverage' : 'View Coverage Map'}
</Button>
</Stack>
</Stack>
<Collapse in={locationsOpen} timeout="auto" unmountOnExit>
<Box sx={{ p: 2, borderTop: '1px dashed', borderColor: 'grey.200', bgcolor: 'grey.50' }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1.5 }}>
<PlaceOutlinedIcon sx={{ color: 'primary.main', fontSize: 20, mr: 1 }} />
<Typography variant="caption" sx={{ color: 'grey.600', fontWeight: 700, letterSpacing: 1 }}>ALL SERVICED AREAS:</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{providerSurveys.map((s, idx) => (
<Chip
key={s.id ?? idx}
label={s.area || s.address}
sx={{
fontWeight: 600,
bgcolor: 'background.paper', color: 'grey.800', border: '1px solid', borderColor: 'grey.300',
'&:hover': { borderColor: 'primary.main', color: 'primary.main', bgcolor: 'primary.lighter' },
transition: 'border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease'
}}
/>
))}
</Box>
</Box>
</Collapse>
</Card>
)}
{/* Pricing Data Grid */}
<Card sx={{ overflow: 'hidden' }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 10 }}>
<CircularProgress size={40} sx={{ color: 'primary.light' }} />
</Box>
) : (
<TableContainer>
<Table sx={{ minWidth: 700 }}>
<TableHead>
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', textTransform: 'uppercase', fontSize: '0.72rem', letterSpacing: 0.4 } }}>
<TableCell>Weight Slab</TableCell>
{providerZones.map(zone => (
<TableCell key={zone} align="center">
{zone}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{providerZones.length === 0 ? (
<TableRow>
<TableCell colSpan={1} align="center" sx={{ py: 8 }}>
<Typography variant="body1" color="text.secondary" sx={{ fontWeight: 500 }}>
No pricing data for this provider yet.
</Typography>
</TableCell>
</TableRow>
) : displaySlabs.length === 0 ? (
<TableRow>
<TableCell colSpan={providerZones.length + 1} align="center" sx={{ py: 8 }}>
<Typography variant="body1" color="text.secondary" sx={{ fontWeight: 500 }}>
No weight slabs match your search.
</Typography>
</TableCell>
</TableRow>
) : (
displaySlabs.map((slab) => (
<TableRow
key={slab}
hover
sx={{ '&:last-child td': { borderBottom: 0 } }}
>
<TableCell sx={{ fontWeight: 600, color: 'grey.800' }}>
{slab}
</TableCell>
{providerZones.map(zone => {
// Exact match — use getColKey so DTDC service_type columns work too
const match = sortedProviderRates.find(r => (r.weight_slab || '').trim() === slab && getColKey(r) === zone);
const hasRate = !!match;
const rateStr = match ? `${match.rate}` : '—';
let tooltipText = '';
if (match && match.updated_by) {
const updater = users.find(u => u.id === match.updated_by);
tooltipText = `Last updated by: ${updater ? updater.first_name : 'ID ' + match.updated_by}`;
}
const openCellEdit = () => {
setEditingRecord({ setEditingRecord({
id: match ? match.id : undefined, id: match ? match.id : undefined,
company: selectedProvider, company: selectedProvider,
@@ -530,52 +343,254 @@ export default function Pricing() {
setFormOpen(true); setFormOpen(true);
}; };
return [
{
key: 'slab',
header: <div style={{ paddingLeft: '12px' }}>Weight Slab</div>,
width: pixel(180),
renderCell: (row) => (
<div style={{ paddingLeft: '12px' }}>
<Text type="body" weight="semibold">{row.slab}</Text>
</div>
)
},
...providerZones.map((zone) => ({
key: zone,
header: zone,
width: proportional(1),
align: 'center',
renderCell: (row) => {
const match = row.rates[zone];
const hasRate = !!match;
return ( return (
<TableCell <div
key={zone} className="matrix-cell"
align="center" style={{
sx={{ position: 'relative',
cursor: isEditMode ? 'pointer' : 'default', display: 'flex',
transition: 'background 0.15s', alignItems: 'center',
'&:hover': isEditMode ? { bgcolor: 'primary.lighter' } : {}, justifyContent: 'center',
}} padding: '4px',
onClick={isEditMode ? openCellEdit : undefined} borderRadius: '6px',
> width: '100%',
<Tooltip title={isEditMode ? (hasRate ? 'Click to edit rate' : 'Click to add rate') : tooltipText} placement="top" arrow disableHoverListener={!isEditMode && !tooltipText}> boxSizing: 'border-box'
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
<Typography
sx={{
display: 'inline-block',
fontWeight: hasRate ? 700 : 400,
color: hasRate ? 'success.dark' : (isEditMode ? 'primary.light' : 'grey.300'),
fontSize: hasRate ? '0.95rem' : '0.85rem',
bgcolor: hasRate ? 'success.lighter' : 'transparent',
px: hasRate ? 1.5 : 0,
py: hasRate ? 0.5 : 0,
borderRadius: 2,
}} }}
> >
{rateStr} {hasRate ? (
</Typography> <Badge variant="red" label={`${match.rate}`} />
{isEditMode && ( ) : (
hasRate <Text type="supporting" color="disabled"></Text>
? <EditOutlinedIcon sx={{ fontSize: 13, color: 'grey.400' }} />
: <AddCircleOutlineIcon sx={{ fontSize: 13, color: 'primary.light' }} />
)} )}
</Box> <button
</Tooltip> type="button"
</TableCell> 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>
); );
})} })}
</TableRow> </div>
))
)} )}
</TableBody> </div>
</Table>
</TableContainer>
)} )}
</Card> </Card>
</Stack> )}
{/* 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 <PricingFormDialog
open={formOpen} open={formOpen}
@@ -587,6 +602,47 @@ export default function Pricing() {
providerColumns={providerZones} providerColumns={providerZones}
providerUsesServiceType={providerUsesServiceType} 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>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,32 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect, useRef } from 'react';
import { import {
Card, Stack, Button, TextField, InputAdornment, Box, Tabs, Tab, Chip, Link, Search,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton, Plus,
TablePagination, Typography, CircularProgress, Alert, Tooltip, Divider, useMediaQuery, Edit,
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Grid Trash2,
} from '@mui/material'; Mail,
import { useTheme } from '@mui/material/styles'; Phone,
import SearchIcon from '@mui/icons-material/Search'; ShieldAlert,
import AddIcon from '@mui/icons-material/Add'; Contact,
import RefreshIcon from '@mui/icons-material/Refresh'; Headphones,
import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; Settings,
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; User
import MailOutlineIcon from '@mui/icons-material/MailOutline'; } from 'lucide-react';
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined'; import { Button } from '@astryxdesign/core/Button';
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined'; import { Card } from '@astryxdesign/core/Card';
import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined'; import { Table, proportional, pixel } from '@astryxdesign/core/Table';
import BadgeOutlinedIcon from '@mui/icons-material/BadgeOutlined'; import { AlertDialog } from '@astryxdesign/core/AlertDialog';
import SupportAgentOutlinedIcon from '@mui/icons-material/SupportAgentOutlined'; import { Badge } from '@astryxdesign/core/Badge';
import ManageAccountsOutlinedIcon from '@mui/icons-material/ManageAccountsOutlined'; import { Text } from '@astryxdesign/core/Text';
import PersonOutlineOutlinedIcon from '@mui/icons-material/PersonOutlineOutlined'; import { IconButton } from '@astryxdesign/core/IconButton';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Banner } from '@astryxdesign/core/Banner';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import StatCard from '@/components/StatCard';
import StatusChip from '@/components/StatusChip'; import StatusChip from '@/components/StatusChip';
import EmptyState from '@/components/EmptyState'; import EmptyState from '@/components/EmptyState';
import UserAvatar from '@/components/UserAvatar';
import TabLabelCount from '@/components/TabLabelCount'; import TabLabelCount from '@/components/TabLabelCount';
import TablePagination from '@/components/TablePagination';
import { fetchUsers, deleteUser } from '@/utils/apiClient'; import { fetchUsers, deleteUser } from '@/utils/apiClient';
import { toUser } from '@/utils/mappers'; import { toUser } from '@/utils/mappers';
import { titleCase } from '@/utils/format'; import { titleCase } from '@/utils/format';
@@ -33,101 +34,39 @@ import UserFormDialog from './UserFormDialog';
// Per-role accent colour + icon, used for the avatar badge and role chip. // Per-role accent colour + icon, used for the avatar badge and role chip.
const ROLE_META = { const ROLE_META = {
admin: { color: 'primary', icon: AdminPanelSettingsOutlinedIcon }, admin: { color: 'primary', icon: ShieldAlert },
manager: { color: 'success', icon: ManageAccountsOutlinedIcon }, manager: { color: 'success', icon: Settings },
rep: { color: 'info', icon: BadgeOutlinedIcon }, rep: { color: 'info', icon: Contact },
support: { color: 'warning', icon: SupportAgentOutlinedIcon } 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. // Name + handle only — no avatar (role is already shown in its own column).
function UserIdentity({ name, email, role }) { function UserIdentity({ name, email }) {
const { color, icon: RoleIcon } = roleMeta(role);
const handle = email ? email.split('@')[0] : ''; const handle = email ? email.split('@')[0] : '';
return ( return (
<Stack direction="row" spacing={1.5} alignItems="center"> <div style={{ minWidth: 0 }}>
<Box sx={{ position: 'relative', display: 'inline-flex' }}> <div style={{ fontWeight: 600, color: '#1e293b', fontSize: '0.8rem', lineHeight: 1.2 }}>{name}</div>
<UserAvatar name={name} size={40} sx={{ border: '2px solid', borderColor: `${color}.lighter` }} /> {handle && <div style={{ fontSize: '0.68rem', color: '#64748b' }}>@{handle}</div>}
<Box </div>
sx={{
position: 'absolute', right: -3, bottom: -3, width: 18, height: 18, borderRadius: '50%',
bgcolor: `${color}.main`, color: '#fff', display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', border: '2px solid #fff'
}}
>
<RoleIcon sx={{ fontSize: 11 }} />
</Box>
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800', lineHeight: 1.25 }}>{name}</Typography>
{handle && <Typography variant="caption" color="text.secondary">@{handle}</Typography>}
</Box>
</Stack>
); );
} }
const ROLE_BADGE_VARIANT = {
primary: 'red',
success: 'success',
info: 'info',
warning: 'warning',
secondary: 'neutral'
};
function RoleCell({ role }) { function RoleCell({ role }) {
const { color, icon: RoleIcon } = roleMeta(role); const { color } = roleMeta(role);
return ( return <Badge variant={ROLE_BADGE_VARIANT[color] || 'neutral'} label={titleCase(role)} />;
<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: 2, borderRadius: 3, border: 1, borderColor: 'divider', bgcolor: 'background.paper' }}>
<Stack direction="row" spacing={1.25} alignItems="flex-start">
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<UserIdentity name={row.name} email={row.email} role={row.role} />
</Box>
<RoleCell role={row.role} />
</Stack>
<Stack spacing={1} sx={{ mt: 1.75 }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ minWidth: 0 }}>
<MailOutlineIcon sx={{ fontSize: 16, color: 'grey.400', flexShrink: 0 }} />
{row.email ? (
<Link href={`mailto:${row.email}`} underline="hover" color="text.primary" noWrap sx={{ fontSize: '0.8125rem' }}>
{row.email}
</Link>
) : <Typography variant="body2" color="text.disabled">No email</Typography>}
</Stack>
<Stack direction="row" spacing={1} alignItems="center">
<PhoneOutlinedIcon sx={{ fontSize: 16, color: 'grey.400', flexShrink: 0 }} />
{row.phone ? (
<Link href={`tel:${row.phone}`} underline="hover" color="text.primary" sx={{ fontSize: '0.8125rem' }}>
{row.phone}
</Link>
) : <Typography variant="body2" color="text.disabled">No phone</Typography>}
</Stack>
</Stack>
<Divider sx={{ my: 1.5 }} />
<Stack direction="row" spacing={1} justifyContent="space-between" alignItems="center">
<Typography variant="caption" color="text.secondary">#{index}</Typography>
<Stack direction="row" spacing={1}>
<Button size="small" variant="outlined" startIcon={<EditOutlinedIcon fontSize="small" />} onClick={() => onEdit(row)}>Edit</Button>
<Button size="small" variant="outlined" color="error" startIcon={<DeleteOutlineIcon fontSize="small" />} onClick={() => onDelete(row)}>Delete</Button>
</Stack>
</Stack>
</Box>
);
} }
export default function TeamUsers() { export default function TeamUsers() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -141,13 +80,25 @@ export default function TeamUsers() {
const [toDelete, setToDelete] = useState(null); const [toDelete, setToDelete] = useState(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
// Guards against out-of-order responses (e.g. React StrictMode's double-invoked
// effect, or a quick double-click on Refresh) so a stale request can never
// clobber a newer one's result.
const loadRequestId = useRef(0);
const load = () => { const load = () => {
const requestId = ++loadRequestId.current;
setLoading(true); setLoading(true);
setError(null); setError(null);
fetchUsers() fetchUsers()
.then((points) => setUsers(points.map(toUser))) .then((points) => {
.catch((e) => setError(e.message || 'Failed to load team users')) if (loadRequestId.current === requestId) setUsers(points.map(toUser));
.finally(() => setLoading(false)); })
.catch((e) => {
if (loadRequestId.current === requestId) setError(e.message || 'Failed to load team users');
})
.finally(() => {
if (loadRequestId.current === requestId) setLoading(false);
});
}; };
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
@@ -166,15 +117,6 @@ export default function TeamUsers() {
return c; return c;
}, [users]); }, [users]);
const roleCounts = useMemo(() => {
const c = { admin: 0, manager: 0, rep: 0, support: 0 };
users.forEach((u) => {
const role = String(u.role || '').toLowerCase();
if (role in c) c[role] += 1;
});
return c;
}, [users]);
const filtered = useMemo( const filtered = useMemo(
() => () =>
users.filter((u) => { users.filter((u) => {
@@ -187,6 +129,99 @@ export default function TeamUsers() {
); );
const paged = filtered.slice(page * rpp, page * rpp + rpp); const paged = filtered.slice(page * rpp, page * rpp + rpp);
const displayRows = useMemo(
() => paged.map((row, i) => ({ ...row, _rowNumber: page * rpp + i + 1 })),
[paged, page, rpp]
);
const columns = useMemo(() => [
{
key: '_rowNumber',
header: <div style={{ paddingLeft: '24px' }}>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 handleSaved = () => { setDialog({ open: false, mode: 'add', initial: null }); load(); };
@@ -204,132 +239,89 @@ export default function TeamUsers() {
}; };
return ( return (
<> <div style={{ fontFamily: 'system-ui, sans-serif' }}>
<PageHeader <PageHeader
title="App Users" title="Team Users"
breadcrumbs={[{ label: 'App Users' }]}
action={ action={
<Stack direction="row" spacing={1.5} sx={{ width: { xs: '100%', sm: 'auto' } }}> <div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'nowrap' }}>
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={load} disabled={loading} sx={{ flex: { xs: 1, sm: 'none' } }}>Refresh</Button> <div style={{ flex: '0 1 320px', minWidth: '200px' }}>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setDialog({ open: true, mode: 'add', initial: null })} sx={{ flex: { xs: 1, sm: 'none' } }}>Add User</Button> <TextInput
</Stack> 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>
} }
/> />
<Grid container spacing={2.5} sx={{ mb: 3 }}> <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)' }}>
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Users" value={users.length} icon={GroupsOutlinedIcon} color="primary" caption="All console members" /></Grid> {/* Tabs */}
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Admins" value={roleCounts.admin} icon={AdminPanelSettingsOutlinedIcon} color="primary" caption="Full access" /></Grid> <div style={{ display: 'flex', padding: '0 24px', borderBottom: '1px solid rgba(5, 54, 89, 0.06)', overflowX: 'auto', gap: '24px' }}>
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Managers" value={roleCounts.manager} icon={ManageAccountsOutlinedIcon} color="primary" caption="Team oversight" /></Grid> {tabs.map((t, i) => (
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Reps & Support" value={roleCounts.rep + roleCounts.support} icon={SupportAgentOutlinedIcon} color="primary" caption="Frontline staff" /></Grid> <div
</Grid> key={t.key}
onClick={() => { setTab(i); setPage(0); }}
<Card sx={{ overflow: 'hidden' }}> style={{
<Box padding: '16px 0',
sx={{ cursor: 'pointer',
px: { xs: 2, sm: 2.5 }, py: 2, borderBottom: 1, borderColor: 'divider', borderBottom: tab === i ? '2px solid #0A1317' : '2px solid transparent',
display: 'flex', alignItems: 'center', gap: 1.5, color: tab === i ? '#0A1317' : '#64748b',
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}66 0%, ${theme.palette.background.paper} 70%)` fontWeight: tab === i ? 600 : 500,
fontSize: '0.875rem',
transition: 'all 0.2s'
}} }}
> >
<Box sx={{ width: 40, height: 40, borderRadius: 2, bgcolor: 'primary.lighter', color: 'primary.main', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}> <TabLabelCount label={t.label} count={counts[t.key] || 0} active={tab === i} />
<GroupsOutlinedIcon fontSize="small" /> </div>
</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>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 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 · PostgreSQL" sx={{ height: 22, fontSize: '0.7rem', bgcolor: 'success.lighter', color: 'success.dark', fontWeight: 600 }} />
</Stack>
)}
</Stack>
<Box sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={Math.min(tab, tabs.length - 1)} onChange={(_, v) => { setTab(v); setPage(0); }} variant="scrollable" scrollButtons="auto">
{tabs.map((t, i) => (
<Tab key={t.key} label={<TabLabelCount label={t.label} count={counts[t.key] || 0} active={tab === i} />} />
))} ))}
</Tabs> </div>
</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 ? ( {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 ? ( ) : paged.length === 0 ? (
<EmptyState title="No team users found" caption="Try a different role or search term, or add a user." /> <EmptyState title="No team users found" caption="Try a different role or search term, or add a user." />
) : isMobile ? (
<Stack spacing={1.5} sx={{ p: 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> <div style={{ paddingTop: '8px', paddingBottom: '8px' }}>
<Table sx={{ minWidth: 800 }}> <Table
<TableHead> data={displayRows}
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', textTransform: 'uppercase', fontSize: '0.72rem', letterSpacing: 0.4 } }}> columns={columns}
<TableCell sx={{ width: 64 }}>S.No</TableCell> idKey="id"
<TableCell>User</TableCell> density="compact"
<TableCell>Email</TableCell> dividers="rows"
<TableCell>Phone</TableCell> hasHover
<TableCell>Role</TableCell> isStriped
<TableCell align="right">Actions</TableCell> textOverflow="truncate"
</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>
)}
<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]}
/> />
</div>
)}
<TablePagination page={page} rpp={rpp} total={filtered.length} onPageChange={setPage} onRppChange={setRpp} />
</Card> </Card>
<UserFormDialog <UserFormDialog
@@ -340,18 +332,22 @@ export default function TeamUsers() {
onSaved={handleSaved} onSaved={handleSaved}
/> />
<Dialog open={!!toDelete} onClose={deleting ? undefined : () => setToDelete(null)}> <AlertDialog
<DialogTitle>Delete user?</DialogTitle> isOpen={!!toDelete}
<DialogContent> onOpenChange={(o) => { if (!o) setToDelete(null); }}
<DialogContentText> title="Delete user?"
This will permanently remove <strong>{toDelete?.name}</strong> from the PostgreSQL database. This cannot be undone. description={toDelete ? `This will permanently remove ${toDelete.name} from the PostgreSQL database. This cannot be undone.` : ''}
</DialogContentText> actionLabel="Delete"
</DialogContent> onAction={confirmDelete}
<DialogActions sx={{ px: 3, py: 2 }}> isActionLoading={deleting}
<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> <style>{`
</Dialog> .link-hover:hover {
</> text-decoration: underline;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
`}</style>
</div>
); );
} }

View File

@@ -1,26 +1,28 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import { Button } from '@astryxdesign/core/Button';
Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, TextField, import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
MenuItem, Alert, CircularProgress, IconButton, useMediaQuery import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
} from '@mui/material'; import { FormLayout } from '@astryxdesign/core/FormLayout';
import { useTheme } from '@mui/material/styles'; import { TextInput } from '@astryxdesign/core/TextInput';
import CloseIcon from '@mui/icons-material/Close'; import { Selector } from '@astryxdesign/core/Selector';
import { Banner } from '@astryxdesign/core/Banner';
import { createUser, updateUser } from '@/utils/apiClient'; import { createUser, updateUser } from '@/utils/apiClient';
const ROLES = ['admin', 'rep', 'manager']; const ROLES = ['admin', 'rep', 'manager'];
const EMPTY = { name: '', email: '', phone: '', role: 'rep', pin: '', password: '' }; const EMPTY = { name: '', email: '', phone: '', role: 'rep', pin: '', password: '' };
const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts); const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts);
const selectorOptions = (opts, v) => withValue(opts, v).map((o) => ({ value: o, label: o.charAt(0).toUpperCase() + o.slice(1) }));
export default function UserFormDialog({ open, mode, initial, onClose, onSaved }) { export default function UserFormDialog({ open, mode, initial, onClose, onSaved }) {
const isEdit = mode === 'edit'; const isEdit = mode === 'edit';
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const [form, setForm] = useState(EMPTY); const [form, setForm] = useState(EMPTY);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const set = (k) => (val) => setForm((f) => ({ ...f, [k]: val }));
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setForm({ ...EMPTY, ...(initial || {}) }); setForm({ ...EMPTY, ...(initial || {}) });
@@ -28,8 +30,6 @@ export default function UserFormDialog({ open, mode, initial, onClose, onSaved }
} }
}, [open, initial]); }, [open, initial]);
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
const handleSave = async () => { const handleSave = async () => {
if (!form.name.trim()) { setError('Name is required.'); return; } if (!form.name.trim()) { setError('Name is required.'); return; }
if (!form.email.trim()) { setError('Email is required.'); return; } if (!form.email.trim()) { setError('Email is required.'); return; }
@@ -61,39 +61,50 @@ export default function UserFormDialog({ open, mode, initial, onClose, onSaved }
}; };
return ( return (
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="sm" fullWidth fullScreen={fullScreen}> <Dialog isOpen={open} onOpenChange={(o) => { if (!o) onClose(); }} width={600} purpose="form">
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <Layout
{isEdit ? 'Edit Team User' : 'Add Team User'} header={<DialogHeader title={isEdit ? 'Edit Team User' : 'Add Team User'} onOpenChange={(o) => { if (!o) onClose(); }} />}
<IconButton onClick={onClose} size="small" disabled={saving}><CloseIcon /></IconButton> content={
</DialogTitle> <LayoutContent>
<DialogContent dividers> <FormLayout>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>} {error && <Banner status="error" title={error} />}
<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> <FormLayout direction="horizontal">
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Email *" value={form.email} onChange={set('email')} /></Grid> <TextInput label="Name" isRequired value={form.name} onChange={set('name')} />
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Phone" value={form.phone} onChange={set('phone')} /></Grid> <TextInput label="Email" type="email" isRequired value={form.email} onChange={set('email')} />
<Grid item xs={12} sm={6}> </FormLayout>
<TextField select fullWidth size="small" label="Role" value={form.role} onChange={set('role')}> <FormLayout direction="horizontal">
{withValue(ROLES, form.role).map((o) => <MenuItem key={o} value={o}>{o}</MenuItem>)} <TextInput label="Phone" value={form.phone} onChange={set('phone')} />
</TextField> <Selector label="Role" options={selectorOptions(ROLES, form.role)} value={form.role} onChange={set('role')} />
</Grid> </FormLayout>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="PIN" value={form.pin} onChange={set('pin')} /></Grid> <FormLayout direction="horizontal">
<Grid item xs={12} sm={6}> <TextInput label="PIN" value={form.pin} onChange={set('pin')} />
<TextField <TextInput
fullWidth size="small" type="password" label={isEdit ? 'New Password (optional)' : 'Password'}
label={isEdit ? 'New Password (optional)' : 'Password *'} isRequired={!isEdit}
type="password"
value={form.password} value={form.password}
onChange={set('password')} onChange={set('password')}
/> />
</Grid> </FormLayout>
</Grid> </FormLayout>
</DialogContent> </LayoutContent>
<DialogActions sx={{ px: 3, py: 2 }}> }
<Button onClick={onClose} disabled={saving}>Cancel</Button> footer={
<Button variant="contained" onClick={handleSave} disabled={saving} startIcon={saving ? <CircularProgress size={16} color="inherit" /> : null}> <LayoutFooter hasDivider>
{isEdit ? 'Save Changes' : 'Create User'} <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
</Button> <Button label="Cancel" variant="ghost" onClick={onClose} isDisabled={saving} />
</DialogActions> <Button
label={isEdit ? 'Save Changes' : 'Create User'}
variant="primary"
onClick={handleSave}
isDisabled={saving}
isLoading={saving}
/>
</div>
</LayoutFooter>
}
/>
</Dialog> </Dialog>
); );
} }

View File

@@ -1,11 +1,16 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { import { MapPin } from 'lucide-react';
Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid, TextField, import { Button } from '@astryxdesign/core/Button';
MenuItem, Box, Typography, Divider, Alert, CircularProgress, IconButton, Autocomplete, Snackbar, useMediaQuery import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
} from '@mui/material'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { useTheme } from '@mui/material/styles'; import { FormLayout } from '@astryxdesign/core/FormLayout';
import CloseIcon from '@mui/icons-material/Close'; import { TextInput } from '@astryxdesign/core/TextInput';
import MyLocationIcon from '@mui/icons-material/MyLocation'; 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'; import { createClient, updateClient } from '@/utils/apiClient';
const BUSINESS_TYPES = ['retail', 'wholesale', 'manufacturer', 'distributor', 'services', 'ecommerce', 'other']; const BUSINESS_TYPES = ['retail', 'wholesale', 'manufacturer', 'distributor', 'services', 'ecommerce', 'other'];
@@ -45,16 +50,17 @@ const EMPTY = {
const formatOption = (s) => typeof s === 'string' ? s.charAt(0).toUpperCase() + s.slice(1).replace(/([A-Z])/g, ' $1').trim() : s; const formatOption = (s) => typeof s === 'string' ? s.charAt(0).toUpperCase() + s.slice(1).replace(/([A-Z])/g, ' $1').trim() : s;
const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts); const withValue = (opts, v) => (v && !opts.includes(v) ? [v, ...opts] : opts);
const selectorOptions = (opts, v) => withValue(opts, v).map((o) => ({ value: o, label: formatOption(o) }));
export default function ClientFormDialog({ open, mode, initial, onClose, onSaved }) { export default function ClientFormDialog({ open, mode, initial, onClose, onSaved }) {
const isEdit = mode === 'edit'; const isEdit = mode === 'edit';
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const [form, setForm] = useState(EMPTY); const [form, setForm] = useState(EMPTY);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [gettingLocation, setGettingLocation] = useState(false); const [gettingLocation, setGettingLocation] = useState(false);
const set = (k) => (val) => setForm((f) => ({ ...f, [k]: val }));
const handleGPS = () => { const handleGPS = () => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
alert("Geolocation is not supported by your browser"); alert("Geolocation is not supported by your browser");
@@ -102,8 +108,6 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
} }
}, [open, initial]); }, [open, initial]);
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
const handleSave = async () => { const handleSave = async () => {
if (!form.name.trim()) { setError('Client name is required.'); return; } if (!form.name.trim()) { setError('Client name is required.'); return; }
setSaving(true); setSaving(true);
@@ -154,131 +158,108 @@ export default function ClientFormDialog({ open, mode, initial, onClose, onSaved
}; };
return ( return (
<> <Dialog isOpen={open} onOpenChange={(o) => { if (!o) onClose(); }} width={800} maxHeight="90dvh" purpose="form">
<Snackbar <Layout
open={!!error} header={<DialogHeader title={isEdit ? 'Edit Client' : 'Add Client'} onOpenChange={(o) => { if (!o) onClose(); }} />}
autoHideDuration={6000} content={
onClose={() => setError(null)} <LayoutContent>
anchorOrigin={{ vertical: 'top', horizontal: 'right' }} <FormLayout>
sx={{ zIndex: 9999 }} {error && <Banner status="error" title={error} />}
>
<Alert onClose={() => setError(null)} severity="error" variant="filled" sx={{ width: '100%', boxShadow: 3 }}>
{error}
</Alert>
</Snackbar>
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="md" fullWidth fullScreen={typeof fullScreen !== 'undefined' ? fullScreen : false}> <Divider label="Business" />
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <FormLayout direction="horizontal">
{isEdit ? 'Edit Client' : 'Add Client'} <TextInput label="Client Name" isRequired value={form.name} onChange={set('name')} />
<IconButton onClick={onClose} size="small" disabled={saving}><CloseIcon /></IconButton> <TextInput label="Phone" value={form.phone} onChange={set('phone')} />
</DialogTitle> </FormLayout>
<DialogContent dividers> <FormLayout direction="horizontal">
<Typography variant="overline" color="text.secondary">Business</Typography> <TextInput label="Email" type="email" value={form.email} onChange={set('email')} />
<Grid container spacing={2} sx={{ mt: 0, mb: 1 }}> {!isEdit && <TextInput label="Password" type="password" value={form.password} onChange={set('password')} />}
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Client Name *" value={form.name} onChange={set('name')} /></Grid> </FormLayout>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Phone" value={form.phone} onChange={set('phone')} /></Grid> <FormLayout direction="horizontal">
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Email" value={form.email} onChange={set('email')} /></Grid> <Selector label="Business Type" options={selectorOptions(BUSINESS_TYPES, form.businessType)} value={form.businessType} onChange={set('businessType')} />
{!isEdit && ( <Selector label="Status" options={selectorOptions(STATUSES, form.status)} value={form.status} onChange={set('status')} />
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Password" type="password" value={form.password} onChange={set('password')} /></Grid> </FormLayout>
)} <FormLayout direction="horizontal">
<Grid item xs={12} sm={6}> <Selector label="Order Frequency" options={selectorOptions(FREQUENCIES, form.frequency)} value={form.frequency} onChange={set('frequency')} />
<TextField select fullWidth size="small" label="Business Type" value={form.businessType} onChange={set('businessType')}> <NumberInput label="Parcel Volume" value={Number(form.parcelVolume) || 0} onChange={set('parcelVolume')} />
{withValue(BUSINESS_TYPES, form.businessType).map((o) => <MenuItem key={o} value={o}>{formatOption(o)}</MenuItem>)} <NumberInput label="Contracts" value={Number(form.activeContracts) || 0} onChange={set('activeContracts')} />
</TextField> </FormLayout>
</Grid> <FormLayout direction="horizontal">
<Grid item xs={12} sm={6}> <div>
<TextField select fullWidth size="small" label="Status" value={form.status} onChange={set('status')}> <TextInput label="Current Provider" list="providers-list" value={form.provider || ''} onChange={set('provider')} />
{withValue(STATUSES, form.status).map((o) => <MenuItem key={o} value={o}>{formatOption(o)}</MenuItem>)} <datalist id="providers-list">
</TextField> {PROVIDERS.map((p) => <option key={p} value={p} />)}
</Grid> </datalist>
<Grid item xs={12} sm={6}> </div>
<TextField select fullWidth size="small" label="Order Frequency" value={form.frequency} onChange={set('frequency')}> <TextInput label="Efficiency" value={form.efficiency} onChange={set('efficiency')} />
{withValue(FREQUENCIES, form.frequency).map((o) => <MenuItem key={o} value={o}>{formatOption(o)}</MenuItem>)} </FormLayout>
</TextField> <TextInput label="Logistics Segment" placeholder="First Mile, Last Mile" value={form.logisticsSegment} onChange={set('logisticsSegment')} />
</Grid>
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Parcel Volume" value={form.parcelVolume} onChange={set('parcelVolume')} /></Grid>
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Active Contracts" value={form.activeContracts} onChange={set('activeContracts')} /></Grid>
<Grid item xs={12} sm={6}>
<Autocomplete
autoHighlight
options={PROVIDERS}
value={form.provider || null}
onChange={(e, newValue) => setForm((f) => ({ ...f, provider: newValue || '' }))}
renderInput={(params) => <TextField {...params} fullWidth size="small" label="Current Provider" />}
/>
</Grid>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Efficiency" value={form.efficiency} onChange={set('efficiency')} /></Grid>
<Grid item xs={12}><TextField fullWidth size="small" label="Logistics Segment" value={form.logisticsSegment} onChange={set('logisticsSegment')} placeholder="First Mile, Last Mile" /></Grid>
</Grid>
<Divider sx={{ my: 1.5 }} /> <Divider label="Location & Transit" />
<Typography variant="overline" color="text.secondary">Location & Transit</Typography> <FormLayout direction="horizontal">
<Grid container spacing={2} sx={{ mt: 0, mb: 1 }}> <div>
<Grid item xs={12} sm={6}> <TextInput label="City" list="cities-list" value={form.city || ''} onChange={set('city')} />
<Autocomplete <datalist id="cities-list">
autoHighlight {CITIES.map((c) => <option key={c} value={c} />)}
options={CITIES} </datalist>
value={form.city || null} </div>
onChange={(e, newValue) => setForm((f) => ({ ...f, city: newValue || '' }))} <TextInput label="State" value={form.businessState} onChange={set('businessState')} />
renderInput={(params) => <TextField {...params} fullWidth size="small" label="City" />} </FormLayout>
/> <FormLayout direction="horizontal">
</Grid> <TextInput label="Pincode" value={form.pincode} onChange={set('pincode')} />
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="State" value={form.businessState} onChange={set('businessState')} /></Grid> <TextInput label="Neighbourhood / Zone" value={form.neighbourhood} onChange={set('neighbourhood')} />
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Pincode" value={form.pincode} onChange={set('pincode')} /></Grid> </FormLayout>
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Neighbourhood / Zone" value={form.neighbourhood} onChange={set('neighbourhood')} /></Grid> <FormLayout direction="horizontal">
<Grid item xs={12} sm={6}> <div>
<Autocomplete <TextInput label="Transit From" list="cities-list-from" value={form.transitFrom || ''} onChange={set('transitFrom')} />
autoHighlight <datalist id="cities-list-from">
options={CITIES} {CITIES.map((c) => <option key={c} value={c} />)}
value={form.transitFrom || null} </datalist>
onChange={(e, newValue) => setForm((f) => ({ ...f, transitFrom: newValue || '' }))} </div>
renderInput={(params) => <TextField {...params} fullWidth size="small" label="Transit From" />} <div>
/> <TextInput label="Transit To" list="cities-list-to" value={form.transitTo || ''} onChange={set('transitTo')} />
</Grid> <datalist id="cities-list-to">
<Grid item xs={12} sm={6}> {CITIES.map((c) => <option key={c} value={c} />)}
<Autocomplete </datalist>
autoHighlight </div>
options={CITIES} </FormLayout>
value={form.transitTo || null} <FormLayout direction="horizontal">
onChange={(e, newValue) => setForm((f) => ({ ...f, transitTo: newValue || '' }))} <NumberInput label="Latitude" value={form.surveyLat === '' ? undefined : Number(form.surveyLat)} onChange={set('surveyLat')} />
renderInput={(params) => <TextField {...params} fullWidth size="small" label="Transit To" />} <NumberInput label="Longitude" value={form.surveyLng === '' ? undefined : Number(form.surveyLng)} onChange={set('surveyLng')} />
/> <div style={{ display: 'flex', alignItems: 'flex-end' }}>
</Grid>
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Latitude" value={form.surveyLat} onChange={set('surveyLat')} /></Grid>
<Grid item xs={12} sm={3}><TextField fullWidth size="small" type="number" label="Longitude" value={form.surveyLng} onChange={set('surveyLng')} /></Grid>
<Grid item xs={12} sm={6} display="flex" alignItems="center">
<Button <Button
variant="outlined" label={gettingLocation ? "Getting GPS..." : "Get GPS Location"}
size="small" variant="secondary"
icon={gettingLocation ? undefined : <MapPin size={16} />}
onClick={handleGPS} onClick={handleGPS}
disabled={gettingLocation} isDisabled={gettingLocation}
startIcon={gettingLocation ? <CircularProgress size={16} /> : <MyLocationIcon />} style={{ width: '100%', height: '40px' }}
fullWidth />
> </div>
Get GPS Location </FormLayout>
</Button> <TextArea label="Survey Address" rows={2} value={form.surveyAddress} onChange={set('surveyAddress')} />
</Grid>
<Grid item xs={12}><TextField fullWidth size="small" label="Survey Address" value={form.surveyAddress} onChange={set('surveyAddress')} multiline minRows={2} /></Grid>
</Grid>
<Divider sx={{ my: 1.5 }} /> <Divider label="Other" />
<Typography variant="overline" color="text.secondary">Other</Typography> <Selector label="Data Consent" options={selectorOptions(CONSENTS, form.dataConsent)} value={form.dataConsent} onChange={set('dataConsent')} />
<Grid container spacing={2} sx={{ mt: 0 }}> <TextArea label="Notes" rows={2} value={form.notes} onChange={set('notes')} />
<Grid item xs={12} sm={6}> </FormLayout>
<TextField select fullWidth size="small" label="Data Consent" value={form.dataConsent} onChange={set('dataConsent')}> </LayoutContent>
{withValue(CONSENTS, form.dataConsent).map((o) => <MenuItem key={o} value={o}>{formatOption(o)}</MenuItem>)} }
</TextField> footer={
</Grid> <LayoutFooter hasDivider>
<Grid item xs={12}><TextField fullWidth size="small" label="Notes" value={form.notes} onChange={set('notes')} multiline minRows={2} /></Grid> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
</Grid> <Button label="Cancel" variant="ghost" onClick={onClose} isDisabled={saving} />
</DialogContent> <Button
<DialogActions sx={{ px: 3, py: 2 }}> label={isEdit ? 'Save Changes' : 'Create Client'}
<Button onClick={onClose} disabled={saving}>Cancel</Button> variant="primary"
<Button variant="contained" onClick={handleSave} disabled={saving} startIcon={saving ? <CircularProgress size={16} color="inherit" /> : null}> onClick={handleSave}
{isEdit ? 'Save Changes' : 'Create Client'} isDisabled={saving}
</Button> isLoading={saving}
</DialogActions> />
</div>
</LayoutFooter>
}
/>
</Dialog> </Dialog>
</>
); );
} }

View File

@@ -1,32 +1,32 @@
import { useState, useMemo, useEffect, Fragment } from 'react'; import { useState, useMemo, useEffect, Fragment } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import dayjs from 'dayjs';
import { import {
Card, Stack, Button, TextField, InputAdornment, Box, Tabs, Tab, Grid, Search,
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton, Plus,
TablePagination, Typography, Collapse, CircularProgress, Alert, Tooltip, Chip, Divider, Link, Edit,
useMediaQuery, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions Trash2,
} from '@mui/material'; ChevronDown,
import { useTheme } from '@mui/material/styles'; ChevronUp,
import SearchIcon from '@mui/icons-material/Search'; Building2,
import AddIcon from '@mui/icons-material/Add'; UserPlus,
import RefreshIcon from '@mui/icons-material/Refresh'; Package,
import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; Handshake,
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; Phone,
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; MapPin,
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; Truck,
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined'; Store,
import FiberNewOutlinedIcon from '@mui/icons-material/FiberNewOutlined'; Info,
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; StickyNote,
import HandshakeOutlinedIcon from '@mui/icons-material/HandshakeOutlined'; ArrowRight,
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined'; Map,
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined'; Contact
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined'; } from 'lucide-react';
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'; import { Button } from '@astryxdesign/core/Button';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { Card } from '@astryxdesign/core/Card';
import NotesOutlinedIcon from '@mui/icons-material/NotesOutlined'; import { Table, TableHeader, TableBody, TableRow, TableHeaderCell, TableCell } from '@astryxdesign/core/Table';
import ArrowRightAltIcon from '@mui/icons-material/ArrowRightAlt'; import { AlertDialog } from '@astryxdesign/core/AlertDialog';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined'; import { TextInput } from '@astryxdesign/core/TextInput';
import BadgeOutlinedIcon from '@mui/icons-material/BadgeOutlined';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import StatCard from '@/components/StatCard'; import StatCard from '@/components/StatCard';
@@ -34,13 +34,13 @@ import StatusChip from '@/components/StatusChip';
import EmptyState from '@/components/EmptyState'; import EmptyState from '@/components/EmptyState';
import UserAvatar from '@/components/UserAvatar'; import UserAvatar from '@/components/UserAvatar';
import TabLabelCount from '@/components/TabLabelCount'; import TabLabelCount from '@/components/TabLabelCount';
import TablePagination from '@/components/TablePagination';
import { fetchClients, deleteClient } from '@/utils/apiClient'; import { fetchClients, deleteClient } from '@/utils/apiClient';
import { toClient } from '@/utils/mappers'; import { toClient } from '@/utils/mappers';
import { titleCase } from '@/utils/format'; import { titleCase } from '@/utils/format';
import ClientFormDialog from './ClientFormDialog'; import ClientFormDialog from './ClientFormDialog';
// Map categorical enum values to a semantic palette color. const consentTone = (v) => ({ full: 'success', basiconly: 'info', none: 'neutral' }[String(v || '').toLowerCase()] || 'neutral');
const consentTone = (v) => ({ full: 'success', basiconly: 'info', none: 'default' }[String(v || '').toLowerCase()] || 'default');
const efficiencyTone = (v) => { const efficiencyTone = (v) => {
const k = String(v || '').toLowerCase(); const k = String(v || '').toLowerCase();
if (/high|good|excellent/.test(k)) return 'success'; if (/high|good|excellent/.test(k)) return 'success';
@@ -49,140 +49,146 @@ const efficiencyTone = (v) => {
return 'info'; return 'info';
}; };
const FIELD_LABEL_SX = { textTransform: 'uppercase', letterSpacing: 0.4, fontSize: '0.68rem', fontWeight: 700, color: 'grey.700' }; function Pill({ label, color = 'neutral' }) {
// A soft, theme-tinted pill for categorical values. Falls back to a dash placeholder.
function Pill({ label, color = 'default' }) {
if (label === undefined || label === null || label === '') { if (label === undefined || label === null || label === '') {
return <Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.500' }}></Typography>; return <span style={{ fontWeight: 500, color: '#94a3b8' }}></span>;
} }
// Soft badge styling mapping
const badgeStyles = {
success: { bg: '#E3F6EC', fg: '#00773B' },
warning: { bg: '#FFF7E0', fg: '#8A6500' },
info: { bg: '#E0F7F8', fg: '#00727B' },
error: { bg: '#FEEAE9', fg: '#A82216' },
primary: { bg: '#F8E0E3', fg: '#9E0E20' },
neutral: { bg: '#f1f5f9', fg: '#475569' }
};
const style = badgeStyles[color] || badgeStyles.neutral;
return ( return (
<Chip <span
size="small" style={{
label={label} display: 'inline-block',
sx={{ padding: '2px 8px',
borderRadius: '9999px',
fontSize: '0.75rem',
fontWeight: 600, fontWeight: 600,
...(color === 'default' backgroundColor: style.bg,
? { bgcolor: 'grey.100', color: 'grey.700' } color: style.fg
: { bgcolor: `${color}.lighter`, color: `${color}.dark` })
}} }}
/> >
{label}
</span>
); );
} }
// Label + arbitrary node value (text, pill, or grouped chips).
function Field({ label, children }) { function Field({ label, children }) {
return ( return (
<Box> <div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<Typography variant="caption" color="text.secondary" sx={FIELD_LABEL_SX}>{label}</Typography> <span style={{ textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: '0.68rem', fontWeight: 700, color: '#475569' }}>{label}</span>
<Box sx={{ mt: 0.5 }}>{children}</Box> <div>{children}</div>
</Box> </div>
); );
} }
function ReadField({ label, value }) { function ReadField({ label, value }) {
return ( return (
<Field label={label}> <Field label={label}>
<Typography variant="body2" sx={{ fontWeight: 500, color: value ? 'grey.800' : 'grey.500', wordBreak: 'break-word' }}> <span style={{ fontWeight: 500, color: value ? '#1e293b' : '#94a3b8', fontSize: '0.875rem', wordBreak: 'break-word' }}>
{value || '—'} {value || '—'}
</Typography> </span>
</Field> </Field>
); );
} }
// A bordered sub-card with a tinted header strip — frames each group of fields.
function SectionCard({ icon: Icon, title, accent = 'primary', children }) { function SectionCard({ icon: Icon, title, accent = 'primary', children }) {
return ( return (
<Box sx={{ height: '100%', borderRadius: 2, border: 1, borderColor: 'divider', bgcolor: 'background.paper', overflow: 'hidden' }}> <div style={{ height: '100%', borderRadius: '8px', border: '1px solid #e2e8f0', backgroundColor: '#ffffff', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ px: 2, py: 1.25, borderBottom: 1, borderColor: 'divider', bgcolor: `${accent}.lighter`, opacity: 0.999 }}> <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '8px', padding: '10px 16px', borderBottom: '1px solid #e2e8f0', backgroundColor: 'rgba(10, 19, 23, 0.04)' }}>
<Icon sx={{ fontSize: 18, color: `${accent}.main` }} /> <Icon size={16} style={{ color: '#0A1317' }} />
<Typography variant="overline" sx={{ fontWeight: 700, color: 'grey.800', letterSpacing: 0.6, lineHeight: 1 }}>{title}</Typography> <span style={{ fontWeight: 700, color: '#1e293b', fontSize: '0.68rem', letterSpacing: '0.05em', textTransform: 'uppercase' }}>{title}</span>
</Stack> </div>
<Stack spacing={1.75} sx={{ p: 2 }}>{children}</Stack> <div style={{ display: 'flex', flexDirection: 'column', gap: '14px', padding: '16px' }}>{children}</div>
</Box> </div>
); );
} }
// Mini KPI tile used inside the Business card for the headline numbers. function StatTile({ label, value, icon: Icon }) {
function StatTile({ label, value, icon: Icon, color = 'primary' }) {
return ( return (
<Box sx={{ flex: 1, p: 1.5, borderRadius: 1.5, border: 1, borderColor: 'divider', bgcolor: 'grey.50' }}> <div style={{ flex: 1, padding: '12px', borderRadius: '6px', border: '1px solid #e2e8f0', backgroundColor: '#f8fafc', display: 'flex', alignItems: 'center', gap: '8px' }}>
<Stack direction="row" spacing={1} alignItems="center"> <div style={{ width: '32px', height: '32px', borderRadius: '4px', backgroundColor: 'rgba(10, 19, 23, 0.08)', color: '#0A1317', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Box sx={{ width: 32, height: 32, borderRadius: 1, bgcolor: `${color}.lighter`, color: `${color}.main`, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}> <Icon size={16} />
<Icon sx={{ fontSize: 18 }} /> </div>
</Box> <div>
<Box> <div style={{ fontWeight: 700, color: '#1e293b', fontSize: '1.125rem', lineHeight: 1.1 }}>{value}</div>
<Typography variant="h5" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.1 }}>{value}</Typography> <div style={{ fontSize: '0.75rem', color: '#64748b' }}>{label}</div>
<Typography variant="caption" color="text.secondary">{label}</Typography> </div>
</Box> </div>
</Stack>
</Box>
); );
} }
// Compact metric used inline in the table so key numbers show without expanding. function Metric({ label, value, color = '#1e293b' }) {
function Metric({ label, value, color = 'grey.800' }) {
return ( return (
<Box sx={{ textAlign: 'center', minWidth: 56 }}> <div style={{ textAlign: 'center', minWidth: '56px' }}>
<Typography variant="body2" sx={{ fontWeight: 700, color, lineHeight: 1.2 }}>{value}</Typography> <div style={{ fontWeight: 700, color, fontSize: '0.875rem', lineHeight: 1.2 }}>{value}</div>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem' }}>{label}</Typography> <div style={{ color: '#64748b', fontSize: '0.65rem' }}>{label}</div>
</Box> </div>
); );
} }
// The expandable detail panel — shared by the desktop table row and the mobile card.
function ClientDetail({ row, onEdit }) { function ClientDetail({ row, onEdit }) {
return ( return (
<Box sx={{ m: 2, borderRadius: 2.5, border: 1, borderColor: 'divider', overflow: 'hidden', boxShadow: '0 4px 16px rgba(0,0,0,0.06)' }}> <div style={{ margin: '16px', borderRadius: '10px', border: '1px solid #e2e8f0', overflow: 'hidden', boxShadow: '0 4px 16px rgba(0,0,0,0.04)', backgroundColor: '#ffffff', fontFamily: 'system-ui, sans-serif' }}>
<Stack <div
direction={{ xs: 'column', sm: 'row' }} style={{
justifyContent="space-between" display: 'flex',
alignItems={{ xs: 'flex-start', sm: 'center' }} flexDirection: 'row',
spacing={1.5} justifyContent: 'space-between',
sx={{ alignItems: 'center',
px: { xs: 2, sm: 2.5 }, py: 2, borderBottom: 1, borderColor: 'divider', padding: '14px 20px',
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}88 0%, ${theme.palette.background.paper} 75%)` borderBottom: '1px solid #e2e8f0',
background: 'linear-gradient(90deg, rgba(10, 19, 23, 0.05) 0%, #ffffff 75%)',
flexWrap: 'wrap',
gap: '12px'
}} }}
> >
<Stack direction="row" spacing={1.5} alignItems="center"> <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<UserAvatar name={row.name} size={40} /> <UserAvatar name={row.name} size={40} />
<Box> <div>
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.2 }}>{row.name}</Typography> <div style={{ fontWeight: 700, fontSize: '1rem', color: '#1e293b', lineHeight: 1.2 }}>{row.name}</div>
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ mt: 0.25 }}> <div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px', color: '#64748b', fontSize: '0.75rem' }}>
<BadgeOutlinedIcon sx={{ fontSize: 14, color: 'grey.400' }} /> <Contact size={12} />
<Typography variant="caption" color="text.secondary">{row.clientId}</Typography> <span>{row.clientId}</span>
</Stack> </div>
</Box> </div>
<StatusChip status={row.status} sx={{ ml: 0.5 }} /> <StatusChip status={row.status} />
</Stack> </div>
<Button size="small" variant="contained" startIcon={<EditOutlinedIcon />} onClick={() => onEdit(row)} sx={{ width: { xs: '100%', sm: 'auto' } }}>Edit Client</Button> <Button size="sm" variant="secondary" icon={<Edit size={14} />} onClick={() => onEdit(row)} label="Edit Client" />
</Stack> </div>
<Box sx={{ p: { xs: 1.5, sm: 2.5 }, bgcolor: 'grey.50' }}> <div style={{ padding: '20px', backgroundColor: '#f8fafc' }}>
<Grid container spacing={2.5} alignItems="stretch"> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '20px' }}>
<Grid item xs={12} md={4}> <SectionCard icon={Store} title="Business" accent="primary">
<SectionCard icon={StorefrontOutlinedIcon} title="Business" accent="primary"> <div style={{ display: 'flex', gap: '12px' }}>
<Stack direction="row" spacing={1.5}> <StatTile label="Parcel Volume" value={Number(row.parcelVolume).toLocaleString()} icon={Package} />
<StatTile label="Parcel Volume" value={Number(row.parcelVolume).toLocaleString()} icon={Inventory2OutlinedIcon} color="primary" /> <StatTile label="Active Contracts" value={row.activeContracts} icon={Handshake} />
<StatTile label="Active Contracts" value={row.activeContracts} icon={HandshakeOutlinedIcon} color="primary" /> </div>
</Stack>
<Field label="Client ID"> <Field label="Client ID">
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 600, color: 'grey.800' }}>{row.clientId}</Typography> <span style={{ fontFamily: 'monospace', fontWeight: 600, color: '#1e293b', fontSize: '0.875rem' }}>{row.clientId}</span>
</Field> </Field>
<Field label="Business Type"><Pill label={row.businessType && titleCase(row.businessType)} color="primary" /></Field> <Field label="Business Type"><Pill label={row.businessType && titleCase(row.businessType)} color="primary" /></Field>
<Field label="Order Frequency"><Pill label={row.frequency && titleCase(row.frequency)} color="info" /></Field> <Field label="Order Frequency"><Pill label={row.frequency && titleCase(row.frequency)} color="info" /></Field>
</SectionCard> </SectionCard>
</Grid>
<Grid item xs={12} md={4}> <SectionCard icon={Truck} title="Logistics" accent="primary">
<SectionCard icon={LocalShippingOutlinedIcon} title="Logistics" accent="primary">
<Field label="Logistics Segment"> <Field label="Logistics Segment">
{row.logisticsSegment ? ( {row.logisticsSegment ? (
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap> <div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{String(row.logisticsSegment).split(/[,/]/).map((seg) => seg.trim()).filter(Boolean).map((seg) => ( {String(row.logisticsSegment).split(/[,/]/).map((seg) => seg.trim()).filter(Boolean).map((seg) => (
<Pill key={seg} label={titleCase(seg)} color="primary" /> <Pill key={seg} label={titleCase(seg)} color="primary" />
))} ))}
</Stack> </div>
) : <Pill label={null} />} ) : <Pill label={null} />}
</Field> </Field>
<ReadField label="Current Provider" value={row.provider} /> <ReadField label="Current Provider" value={row.provider} />
@@ -193,224 +199,190 @@ function ClientDetail({ row, onEdit }) {
.map((s) => s.trim()).filter(Boolean); .map((s) => s.trim()).filter(Boolean);
if (!stops.length) return <Pill label={null} />; if (!stops.length) return <Pill label={null} />;
return ( return (
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap> <div style={{ display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap' }}>
{stops.map((stop, idx) => ( {stops.map((stop, idx) => (
<Fragment key={`${stop}-${idx}`}> <Fragment key={`${stop}-${idx}`}>
{idx > 0 && <ArrowRightAltIcon sx={{ fontSize: 20, color: 'primary.main' }} />} {idx > 0 && <ArrowRight size={14} style={{ color: '#0A1317' }} />}
<Pill label={titleCase(stop)} color={idx === 0 ? 'default' : 'info'} /> <Pill label={titleCase(stop)} color={idx === 0 ? 'neutral' : 'info'} />
</Fragment> </Fragment>
))} ))}
</Stack> </div>
); );
})()} })()}
</Field> </Field>
</SectionCard> </SectionCard>
</Grid>
<Grid item xs={12} md={4}> <SectionCard icon={MapPin} title="Location & Survey" accent="primary">
<SectionCard icon={PlaceOutlinedIcon} title="Location & Survey" accent="primary">
<Field label="City / State"> <Field label="City / State">
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>{[row.city, row.businessState].filter(Boolean).join(', ') || '—'}</Typography> <span style={{ fontWeight: 500, color: '#1e293b', fontSize: '0.875rem' }}>{[row.city, row.businessState].filter(Boolean).join(', ') || '—'}</span>
</Field> </Field>
<ReadField label="Neighbourhood" value={row.neighbourhood} /> <ReadField label="Neighbourhood" value={row.neighbourhood} />
<Field label="Survey Address"> <Field label="Survey Address">
<Stack direction="row" spacing={1} sx={{ p: 1.25, borderRadius: 1.5, bgcolor: 'grey.50', border: 1, borderColor: 'divider' }}> <div style={{ display: 'flex', gap: '8px', padding: '10px', borderRadius: '6px', backgroundColor: '#f8fafc', border: '1px solid #e2e8f0' }}>
<PlaceOutlinedIcon sx={{ fontSize: 16, color: 'grey.400', mt: '2px' }} /> <MapPin size={14} style={{ color: '#94a3b8', marginTop: '2px', flexShrink: 0 }} />
<Typography variant="body2" sx={{ color: 'grey.800', lineHeight: 1.5 }}>{row.surveyAddress || '—'}</Typography> <span style={{ color: '#1e293b', fontSize: '0.875rem', lineHeight: 1.4 }}>{row.surveyAddress || '—'}</span>
</Stack> </div>
</Field> </Field>
<Field label="Coordinates"> <Field label="Coordinates">
{row.surveyLat && row.surveyLng ? ( {row.surveyLat && row.surveyLng ? (
<Stack direction="row" spacing={1.25} alignItems="center" flexWrap="wrap" useFlexGap> <div style={{ display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
<Tooltip title={`${row.surveyLat}, ${row.surveyLng}`} arrow placement="top"> <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ cursor: 'default' }}> <MapPin size={14} style={{ color: '#94a3b8' }} />
<PlaceOutlinedIcon sx={{ fontSize: 16, color: 'grey.400' }} /> <span style={{ fontWeight: 500, color: '#1e293b', fontSize: '0.875rem', borderBottom: '1px dotted #cbd5e1' }}>
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800', borderBottom: '1px dotted', borderColor: 'grey.300' }}>
{[row.neighbourhood, row.city].filter(Boolean).join(', ') || 'Pinned location'} {[row.neighbourhood, row.city].filter(Boolean).join(', ') || 'Pinned location'}
</Typography> </span>
</Stack> </div>
</Tooltip> <a
<Button
component="a"
href={`https://www.google.com/maps?q=${row.surveyLat},${row.surveyLng}`} href={`https://www.google.com/maps?q=${row.surveyLat},${row.surveyLng}`}
target="_blank" rel="noopener" target="_blank" rel="noopener noreferrer"
size="small" variant="outlined" startIcon={<MapOutlinedIcon sx={{ fontSize: 16 }} />} style={{
sx={{ display: 'inline-flex',
py: 0.25, px: 1.25, minWidth: 0, fontSize: '0.75rem', fontWeight: 600, borderRadius: 5, alignItems: 'center',
color: 'primary.main', borderColor: 'primary.100', bgcolor: 'primary.lighter', gap: '4px',
'&:hover': { borderColor: 'primary.main', bgcolor: 'primary.lighter' } padding: '4px 10px',
fontSize: '0.75rem',
fontWeight: 600,
borderRadius: '20px',
color: '#0A1317',
border: '1px solid rgba(10, 19, 23, 0.1)',
backgroundColor: 'rgba(10, 19, 23, 0.05)',
textDecoration: 'none'
}} }}
> >
<Map size={12} />
View on map View on map
</Button> </a>
</Stack> </div>
) : <Typography variant="body2" sx={{ color: 'grey.500' }}></Typography>} ) : <span style={{ color: '#94a3b8' }}></span>}
</Field> </Field>
</SectionCard> </SectionCard>
</Grid> </div>
<Grid item xs={12}> <div style={{ marginTop: '20px' }}>
<SectionCard icon={InfoOutlinedIcon} title="Contact & Compliance" accent="primary"> <SectionCard icon={Info} title="Contact & Compliance" accent="primary">
<Grid container spacing={2.5}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
<Grid item xs={12} sm={6} md={4}><ReadField label="Phone" value={row.phone} /></Grid> <ReadField label="Phone" value={row.phone} />
<Grid item xs={12} sm={6} md={4}><Field label="Data Consent"><Pill label={row.dataConsent && titleCase(row.dataConsent)} color={consentTone(row.dataConsent)} /></Field></Grid> <Field label="Data Consent"><Pill label={row.dataConsent && titleCase(row.dataConsent)} color={consentTone(row.dataConsent)} /></Field>
<Grid item xs={12} sm={6} md={4}><ReadField label="Last Updated" value={row.lastUpdated} /></Grid> <ReadField label="Last Updated" value={row.lastUpdated} />
</div>
{row.notes && ( {row.notes && (
<Grid item xs={12}> <div style={{ padding: '12px', borderRadius: '6px', backgroundColor: '#fffbeb', border: '1px solid #fef3c7', marginTop: '12px' }}>
<Box sx={{ p: 2, borderRadius: 1.5, bgcolor: 'warning.lighter', border: 1, borderColor: 'warning.light' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}> <StickyNote size={14} style={{ color: '#d97706' }} />
<NotesOutlinedIcon sx={{ fontSize: 16, color: 'warning.dark' }} /> <span style={{ color: '#d97706', textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: '0.68rem', fontWeight: 700 }}>Notes</span>
<Typography variant="caption" sx={{ color: 'warning.dark', textTransform: 'uppercase', letterSpacing: 0.4, fontSize: '0.68rem', fontWeight: 700 }}>Notes</Typography> </div>
</Stack> <span style={{ color: '#1e293b', fontSize: '0.875rem' }}>{row.notes}</span>
<Typography variant="body2" sx={{ color: 'grey.800' }}>{row.notes}</Typography> </div>
</Box>
</Grid>
)} )}
</Grid>
</SectionCard> </SectionCard>
</Grid> </div>
</Grid> </div>
</Box> </div>
</Box>
); );
} }
// Mobile presentation of a client — summary card with an expandable detail panel. function ClientRow({ row, onEdit, onDelete }) {
function ClientCard({ row, onEdit, onDelete }) {
const [open, setOpen] = useState(false);
return (
<Box sx={{ borderRadius: 3, border: 1, borderColor: open ? 'primary.light' : 'divider', bgcolor: 'background.paper', overflow: 'hidden' }}>
<Box sx={{ p: 2 }}>
<Stack direction="row" spacing={1.25} alignItems="center">
<UserAvatar name={row.name} size={42} />
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700, color: 'grey.800' }} noWrap>{row.name}</Typography>
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 700, color: 'primary.main' }}>{row.logicalId}</Typography>
</Box>
<StatusChip status={row.status} />
</Stack>
<Stack spacing={1} sx={{ mt: 1.75 }}>
<Stack direction="row" spacing={1} alignItems="center">
<PhoneOutlinedIcon sx={{ fontSize: 16, color: 'grey.400', flexShrink: 0 }} />
<Typography variant="body2">{row.phone || '—'}</Typography>
</Stack>
<Stack direction="row" spacing={1} alignItems="center">
<PlaceOutlinedIcon sx={{ fontSize: 16, color: 'grey.400', flexShrink: 0 }} />
<Typography variant="body2" noWrap>{row.city || '—'}{row.businessState ? `, ${row.businessState}` : ''}</Typography>
</Stack>
</Stack>
<Stack direction="row" spacing={1.5} alignItems="center" divider={<Divider orientation="vertical" flexItem />} sx={{ mt: 1.75 }}>
<Metric label="Parcels" value={Number(row.parcelVolume).toLocaleString()} />
<Metric label="Contracts" value={row.activeContracts} color="primary.main" />
</Stack>
<Divider sx={{ my: 1.5 }} />
<Stack direction="row" spacing={1} justifyContent="space-between" alignItems="center">
<Button size="small" onClick={() => setOpen((o) => !o)} endIcon={open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}>
{open ? 'Hide' : 'Details'}
</Button>
<Stack direction="row" spacing={1}>
<Button size="small" variant="outlined" startIcon={<EditOutlinedIcon fontSize="small" />} onClick={() => onEdit(row)}>Edit</Button>
<Button size="small" variant="outlined" color="error" startIcon={<DeleteOutlineIcon fontSize="small" />} onClick={() => onDelete(row)}>Delete</Button>
</Stack>
</Stack>
</Box>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box sx={{ borderTop: 1, borderColor: 'divider' }}>
<ClientDetail row={row} onEdit={onEdit} />
</Box>
</Collapse>
</Box>
);
}
function ClientRow({ row, index, onEdit, onDelete }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
<Fragment> <Fragment>
<TableRow <TableRow
hover
sx={{
cursor: 'pointer',
'& > *': { borderBottom: open ? 'unset' : undefined },
...(open && { bgcolor: 'primary.lighter', '&:hover': { bgcolor: 'primary.lighter' } })
}}
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
style={{
cursor: 'pointer',
backgroundColor: open ? 'rgba(10, 19, 23, 0.02)' : undefined
}}
> >
<TableCell padding="checkbox"> <TableCell>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setOpen((o) => !o); }}> <button
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />} onClick={(e) => { e.stopPropagation(); setOpen((o) => !o); }}
</IconButton> style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#64748b', display: 'flex', alignItems: 'center' }}
>
{open ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</TableCell> </TableCell>
<TableCell sx={{ whiteSpace: 'nowrap' }}> <TableCell style={{ whiteSpace: 'nowrap' }}>
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 700, color: 'primary.main', bgcolor: 'primary.lighter', px: 1, py: 0.5, borderRadius: 1, border: '1px solid', borderColor: 'primary.light', whiteSpace: 'nowrap' }}> <span style={{ fontFamily: 'monospace', fontWeight: 700, color: '#0A1317', backgroundColor: 'rgba(10, 19, 23, 0.08)', padding: '2px 8px', borderRadius: '4px', border: '1px solid rgba(10, 19, 23, 0.1)', fontSize: '0.75rem' }}>
{row.logicalId} {row.logicalId}
</Typography> </span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Stack direction="row" spacing={1.25} alignItems="center"> <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<UserAvatar name={row.name} size={38} /> <UserAvatar name={row.name} size={38} />
<Box sx={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }}>{row.name}</Typography> <div style={{ fontWeight: 600, color: '#0f172a', fontSize: '0.875rem' }}>{row.name}</div>
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ mt: 0.4 }}> <div style={{ marginTop: '2px' }}>
{row.businessType && ( {row.businessType && (
<Chip <span style={{ fontSize: '0.68rem', fontWeight: 600, backgroundColor: '#f1f5f9', color: '#475569', padding: '2px 6px', borderRadius: '4px' }}>
size="small" {titleCase(row.businessType)}
label={titleCase(row.businessType)} </span>
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, bgcolor: 'grey.100', color: 'grey.700' }}
/>
)} )}
</Stack> </div>
</Box> </div>
</Stack> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Stack direction="row" spacing={0.75} alignItems="center"> <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<PhoneOutlinedIcon sx={{ fontSize: 15, color: 'grey.400' }} /> <Phone size={14} style={{ color: '#94a3b8' }} />
<Box> <div>
<Typography variant="body2">{row.phone || '—'}</Typography> <div style={{ fontSize: '0.875rem', color: '#0f172a' }}>{row.phone || '—'}</div>
{row.frequency && <Typography variant="caption" color="text.secondary">{row.frequency}</Typography>} {row.frequency && <div style={{ fontSize: '0.75rem', color: '#64748b' }}>{row.frequency}</div>}
</Box> </div>
</Stack> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Stack direction="row" spacing={0.75} alignItems="center"> <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<PlaceOutlinedIcon sx={{ fontSize: 15, color: 'grey.400' }} /> <MapPin size={14} style={{ color: '#94a3b8' }} />
<Box> <div>
<Typography variant="body2">{row.city || '—'}{row.businessState ? `, ${row.businessState}` : ''}</Typography> <div style={{ fontSize: '0.875rem', color: '#0f172a' }}>{row.city || '—'}{row.businessState ? `, ${row.businessState}` : ''}</div>
{row.neighbourhood && <Typography variant="caption" color="text.secondary">{row.neighbourhood}</Typography>} {row.neighbourhood && <div style={{ fontSize: '0.75rem', color: '#64748b' }}>{row.neighbourhood}</div>}
</Box> </div>
</Stack> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Stack direction="row" spacing={1.5} alignItems="center" justifyContent="center" divider={<Divider orientation="vertical" flexItem />}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '12px' }}>
<Metric label="Parcels" value={Number(row.parcelVolume).toLocaleString()} /> <Metric label="Parcels" value={Number(row.parcelVolume).toLocaleString()} />
<Metric label="Contracts" value={row.activeContracts} color="primary.main" /> <div style={{ width: '1px', height: '24px', backgroundColor: '#e2e8f0' }} />
</Stack> <Metric label="Contracts" value={row.activeContracts} color="#0A1317" />
</div>
</TableCell> </TableCell>
<TableCell><StatusChip status={row.status} /></TableCell> <TableCell>
<TableCell align="right"> <StatusChip status={row.status} />
<Tooltip title="Edit"><IconButton size="small" onClick={(e) => { e.stopPropagation(); onEdit(row); }}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip> </TableCell>
<Tooltip title="Delete"><IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(row); }}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip> <TableCell style={{ textAlign: 'right' }} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'inline-flex', gap: '8px' }}>
<button
onClick={() => onEdit(row)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '6px', borderRadius: '4px', color: '#475569' }}
className="action-btn"
title="Edit"
>
<Edit size={16} />
</button>
<button
onClick={() => onDelete(row)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '6px', borderRadius: '4px', color: '#dc2626' }}
className="action-btn"
title="Delete"
>
<Trash2 size={16} />
</button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
{open && (
<TableRow> <TableRow>
<TableCell colSpan={8} sx={{ py: 0, borderBottom: open ? undefined : 'none' }}> <TableCell colSpan={8} style={{ padding: 0, backgroundColor: '#f8fafc' }}>
<Collapse in={open} timeout="auto" unmountOnExit>
<ClientDetail row={row} onEdit={onEdit} /> <ClientDetail row={row} onEdit={onEdit} />
</Collapse>
</TableCell> </TableCell>
</TableRow> </TableRow>
)}
</Fragment> </Fragment>
); );
} }
export default function Tenants() { export default function Tenants() {
const theme = useTheme(); const [selectedDate, setSelectedDate] = useState(() => dayjs().format('YYYY-MM-DD'));
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const [clients, setClients] = useState([]); const [clients, setClients] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@@ -421,7 +393,6 @@ export default function Tenants() {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [rpp, setRpp] = useState(10); const [rpp, setRpp] = useState(10);
// Keep the search box in sync when navigated here with a ?q= query (e.g. from the top search bar).
useEffect(() => { useEffect(() => {
const q = searchParams.get('q'); const q = searchParams.get('q');
if (q != null) { setSearch(q); setPage(0); } if (q != null) { setSearch(q); setPage(0); }
@@ -449,7 +420,7 @@ export default function Tenants() {
useEffect(() => { useEffect(() => {
load(); load();
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
load(true); // Silent poll every 10 seconds load(true);
}, 10000); }, 10000);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, []); }, []);
@@ -506,117 +477,135 @@ export default function Tenants() {
}; };
return ( return (
<> <div style={{ fontFamily: 'system-ui, sans-serif' }}>
<PageHeader <PageHeader
title="Clients" title="Clients"
breadcrumbs={[{ label: 'Clients' }]}
action={ action={
<Stack direction="row" spacing={1.5} sx={{ width: { xs: '100%', sm: 'auto' } }}> <div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'nowrap' }}>
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={load} disabled={loading} sx={{ flex: { xs: 1, sm: 'none' } }}>Refresh</Button> <div style={{ flex: '0 1 320px', minWidth: '200px' }}>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setDialog({ open: true, mode: 'add', initial: null })} sx={{ flex: { xs: 1, sm: 'none' } }}>Add Client</Button> <TextInput
</Stack> label="Search clients"
isLabelHidden
placeholder="Search clients…"
startIcon={<Search size={16} />}
hasClear
value={search}
onChange={(v) => { setSearch(v); setPage(0); }}
/>
</div>
<input
type="date"
className="clients-date-picker"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
style={{
padding: '8px 12px',
borderRadius: '10px',
border: '1px solid rgba(5, 54, 89, 0.12)',
fontSize: '0.875rem',
fontWeight: 500,
fontFamily: 'inherit',
color: '#1e293b',
backgroundColor: '#ffffff',
outline: 'none',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.04)',
flexShrink: 0,
width: '150px'
}}
onFocus={(e) => {
e.target.style.borderColor = '#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)';
}}
/>
<Button
variant="primary"
icon={<Plus size={14} />}
onClick={() => setDialog({ open: true, mode: 'add', initial: null })}
label="Add Client"
style={{ flexShrink: 0 }}
/>
</div>
} }
/> />
<Grid container spacing={2.5} sx={{ mb: 3 }}> {/* Stats row */}
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Clients" value={stats.total} icon={ApartmentOutlinedIcon} caption="All registered" /></Grid> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="New Clients" value={stats.newCount} icon={FiberNewOutlinedIcon} color="primary" caption="Awaiting onboarding" /></Grid> <StatCard size="sm" title="Total Clients" value={stats.total} icon={Building2} />
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Parcel Volume" value={stats.parcels.toLocaleString()} icon={Inventory2OutlinedIcon} color="primary" caption="Across all clients" /></Grid> <StatCard size="sm" title="New Clients" value={stats.newCount} icon={UserPlus} color="primary" />
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Active Contracts" value={stats.contracts} icon={HandshakeOutlinedIcon} color="primary" caption="Currently running" /></Grid> <StatCard size="sm" title="Total Parcel Volume" value={stats.parcels.toLocaleString()} icon={Package} color="primary" />
</Grid> <StatCard size="sm" title="Active Contracts" value={stats.contracts} icon={Handshake} color="primary" />
</div>
<Card sx={{ overflow: 'hidden' }}> <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)' }}>
<Box
sx={{ {/* Tabs */}
px: { xs: 2, sm: 2.5 }, py: 2, borderBottom: 1, borderColor: 'divider', <div style={{ display: 'flex', padding: '0 24px', borderBottom: '1px solid rgba(5, 54, 89, 0.06)', overflowX: 'auto', gap: '24px' }}>
display: 'flex', alignItems: 'center', gap: 1.5, {tabs.map((t, i) => (
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}66 0%, ${theme.palette.background.paper} 70%)` <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'
}} }}
> >
<Box sx={{ width: 40, height: 40, borderRadius: 2, bgcolor: 'primary.lighter', color: 'primary.main', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}> <TabLabelCount label={t.label} count={counts[t.key] || 0} active={tab === i} />
<ApartmentOutlinedIcon fontSize="small" /> </div>
</Box>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.2 }}>Client Directory</Typography>
<Typography variant="caption" color="text.secondary">Browse, search and manage every client account</Typography>
</Box>
</Box>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
<TextField
size="small" placeholder="Search by name, phone, city, ID…" 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 ? 'client' : 'clients'}
</Typography>
<Chip size="small" label="live · doormile_clients" sx={{ height: 22, fontSize: '0.7rem', bgcolor: 'success.lighter', color: 'success.dark', fontWeight: 600 }} />
</Stack>
)}
</Stack>
<Box sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={Math.min(tab, tabs.length - 1)} onChange={(_, v) => { setTab(v); setPage(0); }} variant="scrollable" scrollButtons="auto">
{tabs.map((t, i) => (
<Tab key={t.key} label={<TabLabelCount label={t.label} count={counts[t.key] || 0} active={tab === i} />} />
))} ))}
</Tabs> </div>
</Box>
{error && <Alert severity="error" sx={{ m: 2 }} action={<Button color="inherit" size="small" onClick={load}>Retry</Button>}>{error}</Alert>} {error && (
<div style={{ padding: '16px', backgroundColor: '#fef2f2', border: '1px solid #fca5a5', borderRadius: '8px', color: '#b91c1c', margin: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{error}</span>
<Button label="Retry" variant="ghost" size="sm" onClick={() => load(false)} />
</div>
)}
{loading ? ( {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 ? ( ) : paged.length === 0 ? (
<EmptyState title="No clients found" caption="Try a different tab or search term, or add a client." /> <EmptyState title="No clients found" caption="Try a different tab or search term, or add a client." />
) : isMobile ? (
<Stack spacing={1.5} sx={{ p: 2 }}>
{paged.map((row) => (
<ClientCard
key={row.id}
row={row}
onEdit={(r) => setDialog({ open: true, mode: 'edit', initial: r })}
onDelete={(r) => setToDelete(r)}
/>
))}
</Stack>
) : ( ) : (
<TableContainer> <Table density="balanced" dividers="rows" hasHover>
<Table sx={{ minWidth: 900 }}> <TableHeader>
<TableHead> <TableRow isHeaderRow>
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', textTransform: 'uppercase', fontSize: '0.72rem', letterSpacing: 0.4 } }}> <TableHeaderCell style={{ width: '48px' }} />
<TableCell padding="checkbox" /> <TableHeaderCell>ID</TableHeaderCell>
<TableCell>ID</TableCell> <TableHeaderCell>Client</TableHeaderCell>
<TableCell>Client</TableCell> <TableHeaderCell>Contact</TableHeaderCell>
<TableCell>Contact</TableCell> <TableHeaderCell>Location</TableHeaderCell>
<TableCell>Location</TableCell> <TableHeaderCell style={{ textAlign: 'center' }}>Volume</TableHeaderCell>
<TableCell align="center">Volume</TableCell> <TableHeaderCell>Status</TableHeaderCell>
<TableCell>Status</TableCell> <TableHeaderCell style={{ textAlign: 'right' }}>Actions</TableHeaderCell>
<TableCell align="right">Actions</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHeader>
<TableBody> <TableBody>
{paged.map((row, i) => ( {paged.map((row) => (
<ClientRow <ClientRow
key={row.id} key={row.id}
row={row} row={row}
index={page * rpp + i}
onEdit={(r) => setDialog({ open: true, mode: 'edit', initial: r })} onEdit={(r) => setDialog({ open: true, mode: 'edit', initial: r })}
onDelete={(r) => setToDelete(r)} onDelete={(r) => setToDelete(r)}
/> />
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</TableContainer>
)} )}
<TablePagination
component="div" count={filtered.length} page={page} onPageChange={(_, p) => setPage(p)} <TablePagination page={page} rpp={rpp} total={filtered.length} onPageChange={setPage} onRppChange={setRpp} />
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[5, 10, 25]}
/>
</Card> </Card>
<ClientFormDialog <ClientFormDialog
@@ -627,18 +616,22 @@ export default function Tenants() {
onSaved={handleSaved} onSaved={handleSaved}
/> />
<Dialog open={!!toDelete} onClose={deleting ? undefined : () => setToDelete(null)}> <AlertDialog
<DialogTitle>Delete client?</DialogTitle> isOpen={!!toDelete}
<DialogContent> onOpenChange={(o) => { if (!o) setToDelete(null); }}
<DialogContentText> title="Delete client?"
This will permanently remove <strong>{toDelete?.name}</strong> from the doormile_clients collection. This cannot be undone. description={toDelete ? `This will permanently remove ${toDelete.name} from the doormile_clients collection. This cannot be undone.` : ''}
</DialogContentText> actionLabel="Delete"
</DialogContent> onAction={confirmDelete}
<DialogActions sx={{ px: 3, py: 2 }}> isActionLoading={deleting}
<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> <style>{`
</Dialog> .action-btn:hover {
</> background-color: #f1f5f9;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
`}</style>
</div>
); );
} }

View File

@@ -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 } }
}
};
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -39,7 +39,7 @@ export function toClient(raw) {
export function toUser(raw) { export function toUser(raw) {
const p = raw.payload ?? raw; const p = raw.payload ?? raw;
const id = p.id ?? raw.id; const id = p.id ?? raw.id ?? p.uid ?? raw.uid ?? p.email;
return { return {
id, id,
name: p.first_name ? `${p.first_name} ${p.last_name || ''}`.trim() : (p.name || '—'), name: p.first_name ? `${p.first_name} ${p.last_name || ''}`.trim() : (p.name || '—'),

View File

@@ -13,6 +13,7 @@ export default defineConfig({
server: { server: {
port: 3000, port: 3000,
open: true, open: true,
host: true,
// Proxy Qdrant so the api-key stays server-side and the browser avoids CORS. // Proxy Qdrant so the api-key stays server-side and the browser avoids CORS.
// Frontend calls /qdrant/... → forwarded to the Qdrant cluster with the key injected. // Frontend calls /qdrant/... → forwarded to the Qdrant cluster with the key injected.
proxy: { proxy: {