Compare commits

6 Commits

21 changed files with 386 additions and 314 deletions

View File

@@ -9,7 +9,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800&family=Inter:wght@400;500;600&display=swap"
href="https://fonts.googleapis.com/css2?family=Figtree:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
rel="stylesheet"
/>
<title>Doormile CRM</title>

View File

@@ -12,7 +12,7 @@ const load = (factory) => {
<Suspense
fallback={
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh' }}>
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #C01227', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #0A1317', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
<style>{`@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`}</style>
</div>
}

View File

@@ -1,26 +1,57 @@
// ==============================|| DOORMILE WORDMARK LOGO ||============================== //
// Uses the brand wordmark asset (white PNG). `onDark` shows it as-is on dark/red
// surfaces; on light surfaces it is recoloured to near-black. `compact` (e.g. the
// navbar) renders the square navbar mark instead, since the wordmark won't fit.
// collapsed navbar rail) pairs the round navbar mark with a "Doormile" text
// wordmark instead, since the full wordmark image is too wide to fit there.
const LOGO_SRC = '/Doormile-logo.png';
const NAVBAR_MARK_SRC = '/navbarLogo.png';
export default function Logo({ onDark = false, compact = false, height = 26, style = {} }) {
export default function Logo({ onDark = false, compact = false, height = 26, size = 64, style = {} }) {
if (compact) {
return (
<div style={{ display: 'flex', alignItems: 'center', ...style }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 9,
// Nudges the mark so its optical centre lines up with the collapsed
// sidebar's icon column directly beneath it (measured ~2px apart —
// the two live in separate layout trees with their own padding, so
// this small correction keeps them reading as one straight column).
marginInlineStart: 2,
...style
}}
>
<img
src={NAVBAR_MARK_SRC}
alt="Doormile"
// Adjacent text already announces the brand name, so the mark stays
// decorative here rather than making screen readers say it twice.
alt=""
style={{
width: 64,
height: 64,
width: size,
height: size,
display: 'block',
objectFit: 'cover',
flexShrink: 0
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>
);
}

View File

@@ -8,7 +8,7 @@ export default function StatCard({ title, value, icon: Icon, color = 'primary',
// Map theme colors to CSS values
const colorMap = {
primary: { main: '#C01227', light: 'rgba(192, 18, 39, 0.04)', border: 'rgba(192, 18, 39, 0.1)' },
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)' },
@@ -54,8 +54,8 @@ export default function StatCard({ title, value, icon: Icon, color = 'primary',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(192, 18, 39, 0.08)',
color: '#C01227'
backgroundColor: 'rgba(10, 19, 23, 0.08)',
color: '#0A1317'
}}
>
<Icon size={isCompact ? 14 : 18} />

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

@@ -21,13 +21,13 @@
--color-text-primary: #0A1317;
--color-text-secondary: #4E606F;
--color-text-disabled: #A4B0BC;
--color-text-accent: #C01227;
--color-text-accent: #0A1317;
/* Icon tokens */
--color-icon-primary: #0A1317;
--color-icon-secondary: #4E606F;
--color-icon-disabled: #A4B0BC;
--color-icon-accent: #C01227;
--color-icon-accent: #0A1317;
/* Border tokens */
--color-border: rgba(5, 54, 89, 0.1);
@@ -44,19 +44,26 @@
--color-track: #CCD3DB;
--color-shadow: rgba(5, 54, 89, 0.1);
/* Brand accent — Doormile red, replaces the theme-neutral default */
--color-accent: #C01227;
--color-accent-muted: rgba(192, 18, 39, 0.12);
/* 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;
}
/* Apply professional font styling globally */
/* Use Figtree — the font Astryx neutral theme defines via --font-family-body */
*, *::before, *::after {
font-family: 'Public Sans', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
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;
}
@@ -104,8 +111,8 @@ input:not(.search-bar-input):not([class]), select:not([class]) {
}
input:not(.search-bar-input):not([class]):focus, select:not([class]):focus, textarea:not([class]):focus {
border-color: #C01227 !important;
box-shadow: 0 0 0 3px rgba(192, 18, 39, 0.12) !important;
border-color: #0A1317 !important;
box-shadow: 0 0 0 3px rgba(10, 19, 23, 0.12) !important;
}
/* Premium Select Dropdown Styling */
@@ -135,8 +142,8 @@ select:not([class]) {
}
.search-bar-form:focus-within {
border-color: #C01227;
box-shadow: 0 0 0 4px rgba(192, 18, 39, 0.12), 0 10px 15px -3px rgba(0, 0, 0, 0.05);
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 */
}

View File

@@ -8,19 +8,16 @@ import Logo from '@/components/Logo';
import UserAvatar from '@/components/UserAvatar';
import { fetchUsers } from '@/utils/apiClient';
export default function Header() {
export default function Header({ isSidebarCollapsed }) {
const navigate = useNavigate();
let storedUserObj = { name: 'Admin', role: 'Operations Admin', id: 0 };
try {
const storedUser = localStorage.getItem('user');
if (storedUser) storedUserObj = JSON.parse(storedUser);
} catch (e) {}
} catch (e) { }
const [activeUserName, setActiveUserName] = useState(storedUserObj.name || 'Admin');
const displayRole = storedUserObj.role === 'admin' ? 'Administrator' :
storedUserObj.role === 'manager' ? 'Manager' :
storedUserObj.role === 'executive' ? 'Executive' : 'Operations Admin';
useEffect(() => {
fetchUsers().then((users) => {
@@ -36,7 +33,6 @@ export default function Header() {
}, []);
return (
<>
<TopNav
label="Main navigation"
style={{
@@ -46,9 +42,13 @@ export default function Header() {
}}
heading={
<TopNavHeading
logo={<Logo compact height={28} />}
heading="Doormile CRM"
subheading={displayRole}
logo={
<Logo
compact={isSidebarCollapsed}
size={isSidebarCollapsed ? 36 : 32}
height={28}
/>
}
headingHref="/dashboard"
/>
}
@@ -74,6 +74,5 @@ export default function Header() {
</div>
}
/>
</>
);
}

View File

@@ -6,10 +6,12 @@ import navItems from '@/menu/navItems';
// ==============================|| DOORMILE - SIDE NAV ||============================== //
// Thin wrapper around Astryx's SideNav template: sections + items driven by navItems.
// Branding lives in the TopNav heading, so this stays icon+label only (Astryx guidance:
// avoid a SideNavHeading when TopNav already carries app identity).
// 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).
export default function Sidebar() {
export default function Sidebar({ isCollapsed, onCollapsedChange }) {
const location = useLocation();
const isActive = (url) => !!url && location.pathname.startsWith(url);
@@ -17,7 +19,7 @@ export default function Sidebar() {
<>
<SideNav
className="doormile-side-nav"
collapsible={{ defaultIsCollapsed: true, buttonLabel: 'Collapse navigation' }}
collapsible={{ isCollapsed, onCollapsedChange, buttonLabel: 'Collapse navigation' }}
style={{
// White + a right border/shadow matches the TopNav's chrome so the
// top and side nav read as one unified surface instead of two
@@ -77,17 +79,17 @@ export default function Sidebar() {
height: 18px !important;
}
.doormile-side-nav .astryx-side-nav-item[aria-label]:hover {
background-color: rgba(192, 18, 39, 0.08) !important;
background-color: rgba(10, 19, 23, 0.08) !important;
}
.doormile-side-nav .astryx-side-nav-item[aria-label]:focus-visible {
outline: 2px solid rgba(192, 18, 39, 0.4);
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(192, 18, 39, 0.12) !important;
background-color: rgba(10, 19, 23, 0.12) !important;
}
.doormile-side-nav .astryx-side-nav-item[aria-label][data-selected='selected'] .astryx-icon {
color: #C01227 !important;
color: #0A1317 !important;
}
/* Expanded rows: rounded hover/selected states with a left accent
@@ -105,52 +107,61 @@ export default function Sidebar() {
position: relative;
margin-inline: 2px;
height: auto;
padding-block: 8px !important;
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: 8px;
bottom: 8px;
top: 12px;
bottom: 12px;
width: 3px;
border-radius: 3px;
background-color: #C01227;
background-color: #0A1317;
opacity: 0;
transition: opacity 0.15s ease;
}
.doormile-side-nav .astryx-side-nav-item:not([aria-label]):hover {
background-color: rgba(192, 18, 39, 0.05) !important;
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(192, 18, 39, 0.4);
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(192, 18, 39, 0.08) !important;
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: #C01227 !important;
color: #0A1317 !important;
}
/* Collapse/expand toggle (the "<" / ">" chevron button). It sits
right on the sidebar's edge, so the default square ghost-button
hover clips against that border and looks broken. Give it a
clean circular hover detached from the edge instead. */
/* 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;
transform: translateX(-6px) !important;
}
.doormile-side-nav button[aria-label*="sidebar"]:hover,
.doormile-side-nav button[aria-label*="navigation"]:hover {
background-color: rgba(192, 18, 39, 0.08) !important;
background-color: rgba(10, 19, 23, 0.08) !important;
}
`}</style>
</>

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { Outlet } from 'react-router-dom';
import { AppShell } from '@astryxdesign/core/AppShell';
@@ -9,13 +10,17 @@ import Sidebar from './Sidebar';
// and the auto-generated mobile drawer. Pages keep their own internal padding.
export default function MainLayout() {
// Lifted here (rather than left as SideNav's own uncontrolled state) so the
// navbar's logo can track the sidebar's collapse state — one logo, not two.
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(true);
return (
<AppShell
variant="section"
height="fill"
contentPadding={0}
topNav={<Header />}
sideNav={<Sidebar />}
topNav={<Header isSidebarCollapsed={isSidebarCollapsed} />}
sideNav={<Sidebar isCollapsed={isSidebarCollapsed} onCollapsedChange={setIsSidebarCollapsed} />}
mobileNav={{ breakpoint: 'lg' }}
>
<div className="main-content-area" style={{ minHeight: '100%', boxSizing: 'border-box' }}>

View File

@@ -19,6 +19,7 @@ import { Table, proportional, pixel } from '@astryxdesign/core/Table';
import { Badge } from '@astryxdesign/core/Badge';
import { Text, Heading } from '@astryxdesign/core/Text';
import { Banner } from '@astryxdesign/core/Banner';
import { Divider } from '@astryxdesign/core/Divider';
import PageHeader from '@/components/PageHeader';
import StatCard from '@/components/StatCard';
@@ -56,7 +57,7 @@ function Panel({ icon: Icon, title, action, color = 'primary', noPadding = false
alignItems: 'center',
padding: '10px 16px',
borderBottom: '1px solid rgba(5, 54, 89, 0.06)',
background: 'linear-gradient(90deg, rgba(192, 18, 39, 0.03) 0%, rgba(255, 255, 255, 0) 100%)',
background: 'linear-gradient(90deg, rgba(10, 19, 23, 0.03) 0%, rgba(255, 255, 255, 0) 100%)',
gap: '10px',
flexWrap: 'wrap'
}}
@@ -69,8 +70,8 @@ function Panel({ icon: Icon, title, action, color = 'primary', noPadding = false
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(192, 18, 39, 0.08)',
color: '#C01227',
backgroundColor: 'rgba(10, 19, 23, 0.08)',
color: '#0A1317',
flexShrink: 0
}}
>
@@ -210,8 +211,8 @@ export default function Dashboard() {
width: '150px'
}}
onFocus={(e) => {
e.target.style.borderColor = '#C01227';
e.target.style.boxShadow = '0 0 0 3px rgba(192, 18, 39, 0.12)';
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)';
@@ -225,7 +226,7 @@ export default function Dashboard() {
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
<PageHeader title="Dashboard" action={datePickerAction} />
<div style={{ display: 'flex', justifyContent: 'center', padding: '96px 0' }}>
<div className="spinner" style={{ width: '40px', height: '40px', border: '4px solid #f1f5f9', borderTop: '4px solid #C01227', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
<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>
@@ -241,20 +242,25 @@ export default function Dashboard() {
<Banner
status="error"
title={error}
endContent={<Button label="Retry" variant="ghost" size="sm" onClick={load} />}
endContent={<Button label="Reload" variant="ghost" size="sm" onClick={load} />}
/>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '12px', marginBottom: '16px' }}>
{/* 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="info" />
<StatCard size="sm" title="Total Parcel Volume" value={stats.parcels.toLocaleString('en-IN')} icon={Package} color="success" />
<StatCard size="sm" title="Active Contracts" value={stats.contracts} icon={Handshake} color="warning" />
<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>
{/* Main dashboard panels */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '16px', marginBottom: '16px' }}>
<Divider />
{/* Clients section */}
<Heading level={3}>Clients</Heading>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '16px', marginTop: '12px', marginBottom: '16px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', lg: 'repeat(12, 1fr)', gap: '16px' }} className="responsive-row">
{/* Recent Clients */}
<div style={{ gridColumn: 'span 8' }} className="col-span-8">
@@ -265,7 +271,7 @@ export default function Dashboard() {
noPadding
action={recent.length > 0 && (
<Button
label="View all"
label="View more"
variant="ghost"
size="sm"
icon={<ArrowRight size={14} />}
@@ -305,10 +311,16 @@ export default function Dashboard() {
</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 8' }} className="col-span-8">
<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." />
@@ -336,7 +348,7 @@ export default function Dashboard() {
</div>
{/* App Users */}
<div style={{ gridColumn: 'span 4' }} className="col-span-4">
<div style={{ gridColumn: 'span 6' }} className="col-span-6">
<Panel
icon={Users}
title="App Users"
@@ -345,8 +357,8 @@ export default function Dashboard() {
{team.length === 0 ? (
<EmptyState title="No team users" />
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{team.slice(0, 6).map((u) => (
<div style={{ display: 'flex', flexDirection: 'column', maxHeight: '324px', overflowY: 'auto', overflowX: 'hidden' }}>
{team.map((u) => (
<div
key={u.id}
style={{
@@ -387,12 +399,15 @@ export default function Dashboard() {
.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-8, .col-span-4, .col-span-6 {
grid-column: span 1 !important;
}
}
@@ -404,12 +419,12 @@ export default function Dashboard() {
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08) !important;
border-color: rgba(192, 18, 39, 0.2) !important;
border-color: rgba(10, 19, 23, 0.2) !important;
}
/* Team member row hover polish */
.team-row-hover:hover {
background-color: rgba(192, 18, 39, 0.04) !important;
background-color: rgba(10, 19, 23, 0.04) !important;
}
`}</style>
</div>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import {
Save,
Sliders,
@@ -22,6 +22,7 @@ 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';
@@ -40,6 +41,24 @@ const INITIAL_NOTIFY = {
};
const INITIAL_SECURITY = { currentPassword: '', newPassword: '', confirmPassword: '', twoFactor: false };
// No backend endpoint exists yet for organisation settings (confirmed: apiClient.js has
// none, and there's no swagger/docs to design one against — see git history for this
// page). Persisting to localStorage keeps Save/Discard honest and durable across reloads
// without pretending to sync to a server. Password fields are deliberately excluded —
// never write plaintext credentials to localStorage.
const SETTINGS_STORAGE_KEY = 'doormile_settings_v1';
// data-URL logos bloat localStorage fast (5-10MB browser quota); cap the source file.
const MAX_LOGO_BYTES = 1.5 * 1024 * 1024;
function loadSavedSettings() {
try {
const raw = localStorage.getItem(SETTINGS_STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
const NAV = [
{ icon: Sliders, label: 'General', desc: 'Organisation profile' },
{ icon: Bell, label: 'Notifications', desc: 'Alerts & channels' },
@@ -80,15 +99,15 @@ function Section({ icon: Icon, title, subtitle, danger = false, children }) {
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(192, 18, 39, 0.04) 0%, #ffffff 72%)',
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'
}}
>
<div style={{ width: '40px', height: '40px', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: danger ? 'rgba(239, 68, 68, 0.08)' : 'rgba(192, 18, 39, 0.08)', color: danger ? '#ef4444' : '#C01227' }}>
<div style={{ width: '40px', height: '40px', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: danger ? 'rgba(239, 68, 68, 0.08)' : 'rgba(10, 19, 23, 0.08)', color: danger ? '#ef4444' : '#0A1317' }}>
<Icon size={18} />
</div>
<div>
<Heading level={5}>{title}</Heading>
<Heading level={3}>{title}</Heading>
{subtitle && <Text type="supporting" color="secondary">{subtitle}</Text>}
</div>
</div>
@@ -99,7 +118,8 @@ function Section({ icon: Icon, title, subtitle, danger = false, children }) {
function Row({ label, description, controlAlign = 'stretch', children }) {
return (
<div style={{ display: 'flex', alignItems: 'center', padding: '16px 0', borderBottom: '1px solid #f1f5f9', flexWrap: 'wrap', gap: '12px' }} className="settings-row">
<>
<div style={{ display: 'flex', alignItems: 'center', padding: '16px 0', flexWrap: 'wrap', gap: '12px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', width: '240px', flexShrink: 0 }}>
<Text type="body" weight="semibold">{label}</Text>
{description && <Text type="supporting" color="secondary">{description}</Text>}
@@ -115,6 +135,8 @@ function Row({ label, description, controlAlign = 'stretch', children }) {
{children}
</div>
</div>
<Divider className="settings-row-divider" />
</>
);
}
@@ -161,19 +183,58 @@ export default function Settings() {
const [toast, setToast] = useState(false);
const [dirty, setDirty] = useState(false);
const [general, setGeneral] = useState(INITIAL_GENERAL);
const [notify, setNotify] = useState(INITIAL_NOTIFY);
const [security, setSecurity] = useState(INITIAL_SECURITY);
// savedRef tracks the last-persisted snapshot so Discard reverts to "what's actually
// saved" rather than jumping back to hardcoded factory defaults.
const savedRef = useRef(loadSavedSettings());
const saved = savedRef.current;
const [general, setGeneral] = useState(saved?.general || INITIAL_GENERAL);
const [notify, setNotify] = useState(saved?.notify || INITIAL_NOTIFY);
const [security, setSecurity] = useState({ ...INITIAL_SECURITY, twoFactor: saved?.twoFactor ?? false });
const [logoUrl, setLogoUrl] = useState(saved?.logoUrl ?? null);
const [logoError, setLogoError] = useState('');
const logoInputRef = useRef(null);
const 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 save = () => { setToast(true); setDirty(false); setTimeout(() => setToast(false), 2500); };
const handleLogoChange = (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
if (file.size > MAX_LOGO_BYTES) {
setLogoError(`Logo is too large (${(file.size / 1024 / 1024).toFixed(1)}MB) — please use an image under ${MAX_LOGO_BYTES / 1024 / 1024}MB.`);
return;
}
setLogoError('');
const reader = new FileReader();
reader.onload = () => { setLogoUrl(reader.result); setDirty(true); };
reader.readAsDataURL(file);
};
const save = () => {
const snapshot = { general, notify, twoFactor: security.twoFactor, logoUrl };
try {
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(snapshot));
} catch {
// Most likely QuotaExceededError from a large logo data-URL — surface it instead
// of showing a false "saved successfully" toast.
setLogoError('Could not save — local storage is full. Try a smaller logo image.');
return;
}
savedRef.current = snapshot;
setToast(true);
setDirty(false);
setTimeout(() => setToast(false), 2500);
};
const discard = () => {
setGeneral(INITIAL_GENERAL);
setNotify(INITIAL_NOTIFY);
setSecurity(INITIAL_SECURITY);
const last = savedRef.current;
setGeneral(last?.general || INITIAL_GENERAL);
setNotify(last?.notify || INITIAL_NOTIFY);
setSecurity({ ...INITIAL_SECURITY, twoFactor: last?.twoFactor ?? false });
setLogoUrl(last?.logoUrl ?? null);
setLogoError('');
setDirty(false);
};
@@ -213,9 +274,9 @@ export default function Settings() {
</List>
</Card>
<Card style={{ padding: '20px', backgroundColor: 'rgba(192, 18, 39, 0.05)', border: '1px solid rgba(192, 18, 39, 0.1)' }}>
<Card style={{ padding: '20px', backgroundColor: 'rgba(10, 19, 23, 0.05)', border: '1px solid rgba(10, 19, 23, 0.1)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ width: '38px', height: '38px', borderRadius: '8px', backgroundColor: '#C01227', color: '#ffffff', 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' }}>
<HelpCircle size={16} />
</div>
<div>
@@ -243,12 +304,18 @@ export default function Settings() {
gap: '20px',
alignItems: 'center',
padding: '24px',
background: 'linear-gradient(90deg, rgba(192, 18, 39, 0.05) 0%, #ffffff 75%)',
background: 'linear-gradient(90deg, rgba(10, 19, 23, 0.05) 0%, #ffffff 75%)',
flexWrap: 'wrap'
}}
>
<div style={{ width: '64px', height: '64px', borderRadius: '8px', backgroundColor: '#C01227', color: '#ffffff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<div
style={{ width: '64px', height: '64px', borderRadius: '8px', backgroundColor: logoUrl ? 'transparent' : '#0A1317', color: '#ffffff', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, overflow: 'hidden' }}
>
{logoUrl ? (
<img src={logoUrl} alt="Organisation logo" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<Building size={32} />
)}
</div>
<div style={{ flexGrow: 1, minWidth: 0 }}>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
@@ -257,7 +324,17 @@ export default function Settings() {
</div>
<Text type="supporting" color="secondary">{general.supportEmail} · {general.contact}</Text>
</div>
<Button variant="secondary" size="sm" label="Change logo" />
<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>
@@ -383,8 +460,8 @@ export default function Settings() {
grid-column: span 1 !important;
}
}
.settings-row:last-child {
border-bottom: none !important;
.settings-row-divider:last-child {
display: none;
}
`}</style>
</div>

View File

@@ -60,7 +60,7 @@ export default function Login() {
style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(180deg, rgba(15,23,42,0.6) 0%, rgba(192, 18, 39, 0.8) 100%)'
background: 'linear-gradient(180deg, rgba(15,23,42,0.6) 0%, rgba(10, 19, 23, 0.8) 100%)'
}}
/>

View File

@@ -18,6 +18,7 @@ import PageHeader from '@/components/PageHeader';
import EmptyState from '@/components/EmptyState';
import StatusChip from '@/components/StatusChip';
import TabLabelCount from '@/components/TabLabelCount';
import TablePagination from '@/components/TablePagination';
import { fetchClients } from '@/utils/apiClient';
import ClientFormDialog from '../tenants/ClientFormDialog';
@@ -225,7 +226,7 @@ function BookingFormDialog({ open, onClose, onSave, clients, surveys }) {
{/* Customer Details */}
<div>
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#C01227', fontSize: '0.9rem', textTransform: 'uppercase' }}>Customer Details</div>
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#0A1317', fontSize: '0.9rem', textTransform: 'uppercase' }}>Customer Details</div>
<div style={{ display: 'flex', gap: '16px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div style={{ flexGrow: 1, minWidth: '200px' }}>
<Field label="Select Client" inputID="booking-client-input">
@@ -258,7 +259,7 @@ function BookingFormDialog({ open, onClose, onSave, clients, surveys }) {
{/* Assigned Provider */}
<div>
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#C01227', fontSize: '0.9rem', textTransform: 'uppercase' }}>Assigned Provider (From Survey)</div>
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#0A1317', fontSize: '0.9rem', textTransform: 'uppercase' }}>Assigned Provider (From Survey)</div>
<FormLayout direction="horizontal">
<Selector
label="Provider Name" placeholder="Select Provider" hasClear
@@ -278,7 +279,7 @@ function BookingFormDialog({ open, onClose, onSave, clients, surveys }) {
{/* Pickup Location */}
<div>
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#C01227', fontSize: '0.9rem', textTransform: 'uppercase' }}>Pickup Location</div>
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#0A1317', fontSize: '0.9rem', textTransform: 'uppercase' }}>Pickup Location</div>
<FormLayout direction="horizontal">
<TextInput label="Pickup Address" isRequired value={formData.pickupaddress} onChange={set('pickupaddress')} />
<TextInput label="Pickup Pincode" isRequired value={formData.pickuppincode} onChange={set('pickuppincode')} />
@@ -287,7 +288,7 @@ function BookingFormDialog({ open, onClose, onSave, clients, surveys }) {
{/* Delivery Destination */}
<div>
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#C01227', fontSize: '0.9rem', textTransform: 'uppercase' }}>Delivery Destination</div>
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#0A1317', fontSize: '0.9rem', textTransform: 'uppercase' }}>Delivery Destination</div>
<FormLayout>
<TextInput label="Delivery Address" value={formData.deliveryaddress} onChange={set('deliveryaddress')} />
<FormLayout direction="horizontal">
@@ -305,7 +306,7 @@ function BookingFormDialog({ open, onClose, onSave, clients, surveys }) {
{/* Parcel Details */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<div style={{ fontWeight: 700, color: '#C01227', fontSize: '0.9rem', textTransform: 'uppercase' }}>Parcel Details</div>
<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>
@@ -349,7 +350,7 @@ function BookingFormDialog({ open, onClose, onSave, clients, surveys }) {
{/* Price & Insurance */}
<div>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', marginBottom: '12px', flexWrap: 'wrap' }}>
<div style={{ fontWeight: 700, color: '#C01227', fontSize: '0.9rem', textTransform: 'uppercase' }}>Price & Insurance</div>
<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 && (
@@ -388,7 +389,7 @@ function BookingFormDialog({ open, onClose, onSave, clients, surveys }) {
{/* Additional info */}
<div>
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#C01227', fontSize: '0.9rem', textTransform: 'uppercase' }}>Additional Information</div>
<div style={{ fontWeight: 700, marginBottom: '8px', color: '#0A1317', fontSize: '0.9rem', textTransform: 'uppercase' }}>Additional Information</div>
<FormLayout direction="horizontal">
<Selector
label="Select Speed"
@@ -582,8 +583,8 @@ export default function Bookings() {
style={{
padding: '16px 0',
cursor: 'pointer',
borderBottom: sourceFilter === tab.value ? '2px solid #C01227' : '2px solid transparent',
color: sourceFilter === tab.value ? '#C01227' : '#64748b',
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'
@@ -596,7 +597,7 @@ export default function Bookings() {
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px 0' }}>
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #C01227', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #0A1317', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
</div>
) : displayBookings.length === 0 ? (
<EmptyState title="No bookings found" caption="Try a different filter or search term, or create a new booking." />
@@ -616,37 +617,7 @@ export default function Bookings() {
)}
{!loading && displayBookings.length > 0 && (
<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>Rows per page:</span>
<select
value={rpp}
onChange={(e) => { setRpp(Number(e.target.value)); setPage(0); }}
className="rpp-select"
>
{[5, 10, 25].map((opt) => <option key={opt} value={opt}>{opt}</option>)}
</select>
</div>
<span style={{ fontSize: '0.875rem', color: '#475569' }}>
{displayBookings.length === 0 ? '0-0' : `${page * rpp + 1}-${Math.min((page + 1) * rpp, displayBookings.length)}`} of {displayBookings.length}
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<Button
label="Prev"
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
isDisabled={page === 0}
/>
<Button
label="Next"
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.min(Math.ceil(displayBookings.length / rpp) - 1, p + 1))}
isDisabled={page >= Math.ceil(displayBookings.length / rpp) - 1}
/>
</div>
</div>
<TablePagination page={page} rpp={rpp} total={displayBookings.length} onPageChange={setPage} onRppChange={setRpp} />
)}
</Card>

View File

@@ -43,11 +43,11 @@ export default function ComingSoon() {
width: '84px',
padding: '16px 0',
borderRadius: '12px',
backgroundColor: 'rgba(192, 18, 39, 0.08)',
border: '1px solid rgba(192, 18, 39, 0.2)'
backgroundColor: 'rgba(10, 19, 23, 0.08)',
border: '1px solid rgba(10, 19, 23, 0.2)'
}}
>
<div style={{ fontSize: '2rem', fontWeight: 800, color: '#C01227', lineHeight: 1 }}>
<div style={{ fontSize: '2rem', fontWeight: 800, color: '#0A1317', lineHeight: 1 }}>
{String(c.value).padStart(2, '0')}
</div>
<div style={{ fontSize: '0.65rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '1px', marginTop: '4px' }}>
@@ -55,7 +55,7 @@ export default function ComingSoon() {
</div>
</div>
{i < COUNTDOWN.length - 1 && (
<div style={{ fontSize: '2rem', fontWeight: 800, color: '#C01227' }}>:</div>
<div style={{ fontSize: '2rem', fontWeight: 800, color: '#0A1317' }}>:</div>
)}
</div>
))}

View File

@@ -20,10 +20,10 @@ export default function Error404() {
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', alignItems: 'center' }}>
<span style={{ fontWeight: 900, fontSize: 'clamp(3.5rem, 18vw, 8rem)', lineHeight: 1, color: '#C01227' }}>
<span style={{ fontWeight: 900, fontSize: 'clamp(3.5rem, 18vw, 8rem)', lineHeight: 1, color: '#0A1317' }}>
404
</span>
<div style={{ width: '64px', height: '4px', borderRadius: '2px', backgroundColor: '#C01227' }} />
<div style={{ width: '64px', height: '4px', borderRadius: '2px', backgroundColor: '#0A1317' }} />
<Heading level={2}>Page Not Found</Heading>
<div style={{ maxWidth: '460px' }}>
<Text type="body" color="secondary">

View File

@@ -20,10 +20,10 @@ export default function Error500() {
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', alignItems: 'center' }}>
<span style={{ fontWeight: 900, fontSize: 'clamp(3.5rem, 18vw, 8rem)', lineHeight: 1, color: '#C01227' }}>
<span style={{ fontWeight: 900, fontSize: 'clamp(3.5rem, 18vw, 8rem)', lineHeight: 1, color: '#0A1317' }}>
500
</span>
<div style={{ width: '64px', height: '4px', borderRadius: '2px', backgroundColor: '#C01227' }} />
<div style={{ width: '64px', height: '4px', borderRadius: '2px', backgroundColor: '#0A1317' }} />
<Heading level={2}>Internal Server Error</Heading>
<div style={{ maxWidth: '460px' }}>
<Text type="body" color="secondary">

View File

@@ -25,8 +25,8 @@ export default function UnderConstruction() {
width: '110px',
height: '110px',
borderRadius: '50%',
backgroundColor: 'rgba(192, 18, 39, 0.08)',
color: '#C01227',
backgroundColor: 'rgba(10, 19, 23, 0.08)',
color: '#0A1317',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'

View File

@@ -140,7 +140,7 @@ function PricingFormDialog({ open, onClose, onSave, initialData, isCell = false,
{error && <Banner status="error" title={error} />}
{isCell ? (
<Card style={{ padding: '12px 16px', backgroundColor: 'rgba(192, 18, 39, 0.04)', border: '1px solid rgba(192, 18, 39, 0.15)' }}>
<Card style={{ padding: '12px 16px', backgroundColor: 'rgba(10, 19, 23, 0.04)', border: '1px solid rgba(10, 19, 23, 0.15)' }}>
<Text type="label" color="secondary">Updating rate for</Text>
<div style={{ marginTop: '4px' }}>
<Heading level={5}>{formData.company}</Heading>
@@ -445,7 +445,7 @@ export default function Pricing() {
<Card
style={{
borderRadius: '16px',
border: locationsOpen ? '1px solid rgba(192, 18, 39, 0.25)' : '1px solid rgba(5, 54, 89, 0.08)',
border: locationsOpen ? '1px solid rgba(10, 19, 23, 0.25)' : '1px solid rgba(5, 54, 89, 0.08)',
backgroundColor: '#ffffff',
padding: '0',
overflow: 'hidden',
@@ -457,7 +457,7 @@ export default function Pricing() {
onClick={() => setLocationsOpen(!locationsOpen)}
>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<div style={{ width: '32px', height: '32px', borderRadius: '8px', backgroundColor: 'rgba(192, 18, 39, 0.08)', color: '#c01227', fontWeight: 800, fontSize: '0.875rem', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<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' }}>
@@ -552,9 +552,9 @@ export default function Pricing() {
{/* Pricing Data Matrix */}
<Card style={{ padding: '0', borderRadius: '16px', border: '1px solid rgba(5, 54, 89, 0.08)', backgroundColor: '#ffffff', overflow: 'hidden', boxShadow: '0 10px 30px -5px rgba(0,0,0,0.02), 0 4px 12px -3px rgba(0,0,0,0.02)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', padding: '20px 24px', borderBottom: '1px solid rgba(5, 54, 89, 0.06)', background: 'linear-gradient(90deg, rgba(192, 18, 39, 0.03) 0%, #ffffff 70%)', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', 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(192, 18, 39, 0.08)', color: '#C01227', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<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>
@@ -572,7 +572,7 @@ export default function Pricing() {
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px 0' }}>
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #C01227', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
<div 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." />
@@ -621,7 +621,7 @@ export default function Pricing() {
border: 1px solid rgba(5, 54, 89, 0.1);
background-color: #ffffff;
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.12);
color: #C01227;
color: #0A1317;
cursor: pointer;
opacity: 0;
pointer-events: none;
@@ -632,14 +632,14 @@ export default function Pricing() {
pointer-events: auto;
}
.matrix-cell-edit:hover {
background-color: rgba(192, 18, 39, 0.08) !important;
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(192, 18, 39, 0.3) !important;
box-shadow: 0 4px 12px rgba(192, 18, 39, 0.08);
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>

View File

@@ -34,6 +34,7 @@ import { Banner } from '@astryxdesign/core/Banner';
import PageHeader from '@/components/PageHeader';
import StatCard from '@/components/StatCard';
import EmptyState from '@/components/EmptyState';
import TablePagination from '@/components/TablePagination';
function isQuoted(row) {
return !!(row.rate_per_kg && !String(row.rate_per_kg).toLowerCase().includes('not answered'));
@@ -99,8 +100,8 @@ function Field({ label, children }) {
function SectionCard({ icon: Icon, title, children }) {
return (
<div style={{ height: '100%', borderRadius: '8px', border: '1px solid #e2e8f0', backgroundColor: '#ffffff', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '6px', padding: '6px 10px', borderBottom: '1px solid #e2e8f0', backgroundColor: 'rgba(192, 18, 39, 0.08)' }}>
<Icon size={12} style={{ color: '#c01227' }} />
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '6px', padding: '6px 10px', borderBottom: '1px solid #e2e8f0', backgroundColor: 'rgba(10, 19, 23, 0.08)' }}>
<Icon size={12} style={{ color: '#0a1317' }} />
<span style={{ fontWeight: 700, color: '#1e293b', fontSize: '0.64rem', letterSpacing: '0.6px', textTransform: 'uppercase' }}>{title}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', padding: '8px 10px' }}>{children}</div>
@@ -320,7 +321,7 @@ function SurveyFormDialog({ open, onClose, onSave, initialData }) {
<div style={{ borderTop: '1px dashed #e2e8f0', paddingTop: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: '#C01227' }}>SERVICEABLE PINCODES</span>
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: '#0A1317' }}>SERVICEABLE PINCODES</span>
<Button size="sm" variant="secondary" icon={<Plus size={12} />} onClick={addPincode} label="Add Pincode" />
</div>
{(formData.pincodes || []).map((pincode, index) => (
@@ -347,7 +348,7 @@ function SurveyFormDialog({ open, onClose, onSave, initialData }) {
{/* Pricing slabs */}
<div style={{ borderTop: '1px dashed #e2e8f0', paddingTop: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: '#C01227' }}>PRICING SLABS (OPTIONAL)</span>
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: '#0A1317' }}>PRICING SLABS (OPTIONAL)</span>
<Button size="sm" variant="secondary" icon={<Plus size={12} />} onClick={addSlab} label="Add Slab" />
</div>
{(formData.slabs || []).map((slab, index) => (
@@ -419,11 +420,11 @@ function BranchCard({ row, onEdit, onDelete, users }) {
}}
className="branch-card"
>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '10px' }} onClick={() => setOpen(!open)}>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'nowrap', gap: '10px', overflow: 'hidden' }} onClick={() => setOpen(!open)}>
{/* Left: Location & Contact */}
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', minWidth: '200px' }}>
<div style={{ padding: '6px', borderRadius: '6px', backgroundColor: 'rgba(192, 18, 39, 0.05)', color: '#c01227', display: 'flex' }}>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', minWidth: '160px', flexShrink: 0 }}>
<div style={{ padding: '6px', borderRadius: '6px', backgroundColor: 'rgba(10, 19, 23, 0.05)', color: '#0a1317', display: 'flex' }}>
<MapPin size={14} />
</div>
<div>
@@ -442,8 +443,8 @@ function BranchCard({ row, onEdit, onDelete, users }) {
</div>
{/* Middle: Rates */}
<div style={{ display: 'flex', gap: '16px', flexGrow: 1 }}>
<div style={{ width: '130px', flexShrink: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', gap: '16px', flexGrow: 1, overflow: 'hidden' }}>
<div style={{ flex: 1, overflow: 'hidden', minWidth: 0 }}>
<span style={{ fontSize: '0.6rem', color: '#64748b', fontWeight: 700, letterSpacing: '0.05em', display: 'block', marginBottom: '2px', textTransform: 'uppercase' }}>Rate/kg</span>
<Pill label={row.rate_per_kg} color={quoted ? 'success' : 'warning'} truncate />
</div>
@@ -534,9 +535,9 @@ function BranchCard({ row, onEdit, onDelete, users }) {
fontSize: '0.7rem',
fontWeight: 600,
borderRadius: '4px',
color: '#c01227',
border: '1px solid rgba(192, 18, 39, 0.2)',
backgroundColor: 'rgba(192, 18, 39, 0.05)',
color: '#0a1317',
border: '1px solid rgba(10, 19, 23, 0.2)',
backgroundColor: 'rgba(10, 19, 23, 0.05)',
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
@@ -589,7 +590,7 @@ function CompanyGroup({ companyName, branches, onEdit, onDelete, users, isLast }
onClick={() => setOpen(!open)}
>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<div style={{ width: '32px', height: '32px', borderRadius: '8px', backgroundColor: 'rgba(192, 18, 39, 0.08)', color: '#C01227', fontWeight: 700, fontSize: '0.8rem', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<div style={{ width: '32px', height: '32px', borderRadius: '8px', backgroundColor: 'rgba(10, 19, 23, 0.08)', color: '#0A1317', fontWeight: 700, fontSize: '0.8rem', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{(companyName || 'A')[0].toUpperCase()}
</div>
<div>
@@ -798,38 +799,7 @@ export default function Survey() {
</div>
)}
{/* Pagination Section */}
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', padding: '12px 24px', borderTop: '1px solid #e2e8f0', gap: '20px', flexWrap: 'wrap', backgroundColor: '#ffffff' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.875rem', color: '#475569' }}>
<span>Companies per page:</span>
<select
value={rowsPerPage}
onChange={(e) => { setRowsPerPage(Number(e.target.value)); setPage(0); }}
className="rpp-select"
>
{[5, 10, 25].map((opt) => <option key={opt} value={opt}>{opt}</option>)}
</select>
</div>
<span style={{ fontSize: '0.875rem', color: '#475569' }}>
{companyKeys.length === 0 ? '0-0' : `${page * rowsPerPage + 1}-${Math.min((page + 1) * rowsPerPage, companyKeys.length)}`} of {companyKeys.length}
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<Button
label="Prev"
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
isDisabled={page === 0}
/>
<Button
label="Next"
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.min(Math.ceil(companyKeys.length / rowsPerPage) - 1, p + 1))}
isDisabled={page >= Math.ceil(companyKeys.length / rowsPerPage) - 1}
/>
</div>
</div>
<TablePagination page={page} rpp={rowsPerPage} total={companyKeys.length} onPageChange={setPage} onRppChange={setRowsPerPage} unitLabel="Companies" />
</Card>
<SurveyFormDialog

View File

@@ -26,6 +26,7 @@ import PageHeader from '@/components/PageHeader';
import StatusChip from '@/components/StatusChip';
import EmptyState from '@/components/EmptyState';
import TabLabelCount from '@/components/TabLabelCount';
import TablePagination from '@/components/TablePagination';
import { fetchUsers, deleteUser } from '@/utils/apiClient';
import { toUser } from '@/utils/mappers';
import { titleCase } from '@/utils/format';
@@ -136,7 +137,7 @@ export default function TeamUsers() {
const columns = useMemo(() => [
{
key: '_rowNumber',
header: <div style={{ paddingLeft: '24px', paddingTop: '12px' }}>S.No</div>,
header: <div style={{ paddingLeft: '24px' }}>S.No</div>,
width: pixel(72),
renderCell: (r) => (
<div style={{ paddingLeft: '24px' }}>
@@ -146,20 +147,20 @@ export default function TeamUsers() {
},
{
key: 'name',
header: <div style={{ padding: '12px 16px 0' }}>User</div>,
header: <div style={{ padding: '0 12px' }}>User</div>,
width: proportional(2),
renderCell: (r) => (
<div style={{ padding: '10px 16px' }}>
<div style={{ padding: '2px 12px' }}>
<UserIdentity name={r.name} email={r.email} />
</div>
)
},
{
key: 'email',
header: <div style={{ padding: '12px 16px 0' }}>Email</div>,
header: <div style={{ padding: '0 12px' }}>Email</div>,
width: proportional(2),
renderCell: (r) => (
<div style={{ padding: '10px 16px' }}>
<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' }} />
@@ -171,10 +172,10 @@ export default function TeamUsers() {
},
{
key: 'phone',
header: <div style={{ padding: '12px 16px 0' }}>Phone</div>,
header: <div style={{ padding: '0 12px' }}>Phone</div>,
width: proportional(1),
renderCell: (r) => (
<div style={{ padding: '10px 16px' }}>
<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' }} />
@@ -186,17 +187,17 @@ export default function TeamUsers() {
},
{
key: 'role',
header: <div style={{ padding: '12px 16px 0' }}>Role</div>,
header: <div style={{ padding: '0 12px' }}>Role</div>,
width: pixel(140),
renderCell: (r) => (
<div style={{ padding: '10px 16px' }}>
<div style={{ padding: '2px 12px' }}>
<RoleCell role={r.role} />
</div>
)
},
{
key: 'actions',
header: <div style={{ paddingRight: '24px', paddingTop: '12px' }}>Actions</div>,
header: <div style={{ paddingRight: '24px' }}>Actions</div>,
width: pixel(120),
align: 'end',
renderCell: (r) => (
@@ -252,6 +253,8 @@ export default function TeamUsers() {
hasClear
value={search}
onChange={(v) => { setSearch(v); setPage(0); }}
htmlName="team-users-search"
autoComplete="off"
/>
</div>
<Button
@@ -275,8 +278,8 @@ export default function TeamUsers() {
style={{
padding: '16px 0',
cursor: 'pointer',
borderBottom: tab === i ? '2px solid #C01227' : '2px solid transparent',
color: tab === i ? '#C01227' : '#64748b',
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'
@@ -299,7 +302,7 @@ export default function TeamUsers() {
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px 0' }}>
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #C01227', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #0A1317', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
</div>
) : paged.length === 0 ? (
<EmptyState title="No team users found" caption="Try a different role or search term, or add a user." />
@@ -318,38 +321,7 @@ export default function TeamUsers() {
</div>
)}
{/* Pagination bar */}
<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>Rows per page:</span>
<select
value={rpp}
onChange={(e) => { setRpp(Number(e.target.value)); setPage(0); }}
className="rpp-select"
>
{[5, 10, 25].map((opt) => <option key={opt} value={opt}>{opt}</option>)}
</select>
</div>
<span style={{ fontSize: '0.875rem', color: '#475569' }}>
{filtered.length === 0 ? '0-0' : `${page * rpp + 1}-${Math.min((page + 1) * rpp, filtered.length)}`} of {filtered.length}
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<Button
label="Prev"
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
isDisabled={page === 0}
/>
<Button
label="Next"
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.min(Math.ceil(filtered.length / rpp) - 1, p + 1))}
isDisabled={page >= Math.ceil(filtered.length / rpp) - 1}
/>
</div>
</div>
<TablePagination page={page} rpp={rpp} total={filtered.length} onPageChange={setPage} onRppChange={setRpp} />
</Card>
<UserFormDialog

View File

@@ -34,6 +34,7 @@ import StatusChip from '@/components/StatusChip';
import EmptyState from '@/components/EmptyState';
import UserAvatar from '@/components/UserAvatar';
import TabLabelCount from '@/components/TabLabelCount';
import TablePagination from '@/components/TablePagination';
import { fetchClients, deleteClient } from '@/utils/apiClient';
import { toClient } from '@/utils/mappers';
import { titleCase } from '@/utils/format';
@@ -104,8 +105,8 @@ function ReadField({ label, value }) {
function SectionCard({ icon: Icon, title, accent = 'primary', children }) {
return (
<div style={{ height: '100%', borderRadius: '8px', border: '1px solid #e2e8f0', backgroundColor: '#ffffff', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '8px', padding: '10px 16px', borderBottom: '1px solid #e2e8f0', backgroundColor: 'rgba(192, 18, 39, 0.04)' }}>
<Icon size={16} style={{ color: '#C01227' }} />
<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 size={16} style={{ color: '#0A1317' }} />
<span style={{ fontWeight: 700, color: '#1e293b', fontSize: '0.68rem', letterSpacing: '0.05em', textTransform: 'uppercase' }}>{title}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px', padding: '16px' }}>{children}</div>
@@ -116,7 +117,7 @@ function SectionCard({ icon: Icon, title, accent = 'primary', children }) {
function StatTile({ label, value, icon: Icon }) {
return (
<div style={{ flex: 1, padding: '12px', borderRadius: '6px', border: '1px solid #e2e8f0', backgroundColor: '#f8fafc', display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '32px', height: '32px', borderRadius: '4px', backgroundColor: 'rgba(192, 18, 39, 0.08)', color: '#C01227', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: '32px', height: '32px', borderRadius: '4px', backgroundColor: 'rgba(10, 19, 23, 0.08)', color: '#0A1317', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Icon size={16} />
</div>
<div>
@@ -147,7 +148,7 @@ function ClientDetail({ row, onEdit }) {
alignItems: 'center',
padding: '14px 20px',
borderBottom: '1px solid #e2e8f0',
background: 'linear-gradient(90deg, rgba(192, 18, 39, 0.05) 0%, #ffffff 75%)',
background: 'linear-gradient(90deg, rgba(10, 19, 23, 0.05) 0%, #ffffff 75%)',
flexWrap: 'wrap',
gap: '12px'
}}
@@ -201,7 +202,7 @@ function ClientDetail({ row, onEdit }) {
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap' }}>
{stops.map((stop, idx) => (
<Fragment key={`${stop}-${idx}`}>
{idx > 0 && <ArrowRight size={14} style={{ color: '#C01227' }} />}
{idx > 0 && <ArrowRight size={14} style={{ color: '#0A1317' }} />}
<Pill label={titleCase(stop)} color={idx === 0 ? 'neutral' : 'info'} />
</Fragment>
))}
@@ -242,9 +243,9 @@ function ClientDetail({ row, onEdit }) {
fontSize: '0.75rem',
fontWeight: 600,
borderRadius: '20px',
color: '#C01227',
border: '1px solid rgba(192, 18, 39, 0.1)',
backgroundColor: 'rgba(192, 18, 39, 0.05)',
color: '#0A1317',
border: '1px solid rgba(10, 19, 23, 0.1)',
backgroundColor: 'rgba(10, 19, 23, 0.05)',
textDecoration: 'none'
}}
>
@@ -289,7 +290,7 @@ function ClientRow({ row, onEdit, onDelete }) {
onClick={() => setOpen((o) => !o)}
style={{
cursor: 'pointer',
backgroundColor: open ? 'rgba(192, 18, 39, 0.02)' : undefined
backgroundColor: open ? 'rgba(10, 19, 23, 0.02)' : undefined
}}
>
<TableCell>
@@ -301,7 +302,7 @@ function ClientRow({ row, onEdit, onDelete }) {
</button>
</TableCell>
<TableCell style={{ whiteSpace: 'nowrap' }}>
<span style={{ fontFamily: 'monospace', fontWeight: 700, color: '#C01227', backgroundColor: 'rgba(192, 18, 39, 0.08)', padding: '2px 8px', borderRadius: '4px', border: '1px solid rgba(192, 18, 39, 0.1)', fontSize: '0.75rem' }}>
<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}
</span>
</TableCell>
@@ -342,7 +343,7 @@ function ClientRow({ row, onEdit, onDelete }) {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '12px' }}>
<Metric label="Parcels" value={Number(row.parcelVolume).toLocaleString()} />
<div style={{ width: '1px', height: '24px', backgroundColor: '#e2e8f0' }} />
<Metric label="Contracts" value={row.activeContracts} color="#C01227" />
<Metric label="Contracts" value={row.activeContracts} color="#0A1317" />
</div>
</TableCell>
<TableCell>
@@ -514,8 +515,8 @@ export default function Tenants() {
width: '150px'
}}
onFocus={(e) => {
e.target.style.borderColor = '#C01227';
e.target.style.boxShadow = '0 0 0 3px rgba(192, 18, 39, 0.12)';
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)';
@@ -552,8 +553,8 @@ export default function Tenants() {
style={{
padding: '16px 0',
cursor: 'pointer',
borderBottom: tab === i ? '2px solid #C01227' : '2px solid transparent',
color: tab === i ? '#C01227' : '#64748b',
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'
@@ -573,7 +574,7 @@ export default function Tenants() {
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px 0' }}>
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #C01227', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
<div className="spinner" style={{ width: '32px', height: '32px', border: '3px solid #f1f5f9', borderTop: '3px solid #0A1317', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
</div>
) : paged.length === 0 ? (
<EmptyState title="No clients found" caption="Try a different tab or search term, or add a client." />
@@ -604,38 +605,7 @@ export default function Tenants() {
</Table>
)}
{/* Pagination bar */}
<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>Rows per page:</span>
<select
value={rpp}
onChange={(e) => { setRpp(Number(e.target.value)); setPage(0); }}
className="rpp-select"
>
{[5, 10, 25].map((opt) => <option key={opt} value={opt}>{opt}</option>)}
</select>
</div>
<span style={{ fontSize: '0.875rem', color: '#475569' }}>
{filtered.length === 0 ? '0-0' : `${page * rpp + 1}-${Math.min((page + 1) * rpp, filtered.length)}`} of {filtered.length}
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<Button
label="Prev"
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
isDisabled={page === 0}
/>
<Button
label="Next"
variant="secondary"
size="sm"
onClick={() => setPage((p) => Math.min(Math.ceil(filtered.length / rpp) - 1, p + 1))}
isDisabled={page >= Math.ceil(filtered.length / rpp) - 1}
/>
</div>
</div>
<TablePagination page={page} rpp={rpp} total={filtered.length} onPageChange={setPage} onRppChange={setRpp} />
</Card>
<ClientFormDialog