conversion done with astryx

This commit is contained in:
2026-08-13 18:33:22 +05:30
parent d935542b11
commit 0daec5311d
19 changed files with 3509 additions and 5144 deletions

9
.vscode/css_custom_data.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"version": 1.1,
"atDirectives": [
{
"name": "@theme",
"description": "Tailwind CSS v4 theme configuration."
}
]
}

View File

@@ -1,3 +1,4 @@
{
"css.lint.unknownAtRules": "ignore"
"css.lint.unknownAtRules": "ignore",
"css.customData": [".vscode/css_custom_data.json"]
}

View File

@@ -9,33 +9,18 @@ import PosStaffPanelAstryx from './components/spike/PosStaffPanelAstryx'; // PHA
import ThemeShowcase from './components/spike/ThemeShowcase'; // PHASE 2 gate — throwaway
import ConsoleUiProbe from './components/spike/ConsoleUiProbe'; // PHASE 3 verification — throwaway
import {
Network,
Truck,
Sliders,
Calendar,
FileCheck,
Building,
CheckCircle2,
Clock,
ShieldCheck,
Send,
HelpCircle,
Database,
ArrowRight,
X,
Search,
Plus,
MapPin,
Phone,
Activity,
TrendingUp,
Award,
// Passed by reference into the Header's `icon` slot rather than rendered as
// JSX here, which is why a JSX-only usage scan misses them.
LayoutDashboard,
Layers,
Store,
TrendingUp,
Settings,
LayoutDashboard,
Users,
Box
Truck
} from 'lucide-react';
import { MainSection } from './types';
@@ -43,7 +28,6 @@ import {
useFiestaTenantLocations,
useFiestaLocationSummary,
useFiestaUpdateLocation,
useFiestaCreateLocation,
useFiestaOrderSummary,
} from './services/fiestaQueries';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr, num as fnum } from './services/fiestaApi';
@@ -60,11 +44,18 @@ import PosErrorBoundary from './components/PosErrorBoundary';
import LoginView from './components/LoginView';
import UserStorePage from './components/UserStorePage';
import SuperAdminPage from './components/SuperAdminPage';
import AwaitingApi from './components/AwaitingApi';
import ComparisonModal from './components/ComparisonModal';
import CatalogueBrowser from './components/CatalogueBrowser';
import type { AuthUser } from './services/auth';
import ragulStoreCover from './assets/images/store_front_view_1780299351800.png';
import { useNotify } from './components/Toasts';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Switch } from '@astryxdesign/core/Switch';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Spinner } from '@astryxdesign/core/Spinner';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
const AUTH_STORAGE_KEY = 'nearledaily.auth';
@@ -115,6 +106,9 @@ export default function App() {
// Navigation indicators states
const location = useLocation();
const navigate = useNavigate();
// PHASE 6 — replaces the last four alert() calls in the app. A native alert
// blocks the whole browser until dismissed; a toast does not.
const { notifyOk } = useNotify();
// Derive current section from URL path, default to 'dashboard'
const pathParts = location.pathname.split('/');
@@ -143,9 +137,10 @@ export default function App() {
const tenantId = authUser?.tenantid || FIESTA_TENANT_ID;
// ── Live data for the secondary sections (Fiesta) ─────────────────────────
// Stores ← tenant locations + per-location order summary (seeded into local
// state so the "Add Store" handler keeps working). Users management now lives
// under Settings → Users & Access (see UsersPanel).
// Stores ← tenant locations + per-location order summary, held in local state
// so the per-card status toggle can update a row optimistically before the
// refetch lands. Users management lives under Settings → Users & Access
// (see UsersPanel); outlet creation lives under Settings → Outlets.
const locationsQ = useFiestaTenantLocations(tenantId);
const locSummaryQ = useFiestaLocationSummary(tenantId);
const updateLocation = useFiestaUpdateLocation();
@@ -238,41 +233,19 @@ export default function App() {
}
}, [locationsQ.data, locSummaryQ.data]);
// Secondary sub-sections modals triggers
const [showAddStoreModal, setShowAddStoreModal] = useState(false);
// New forms states
const [newStore, setNewStore] = useState({ name: '', zone: '', lead: '', sales: '₹1,50,000' });
// Form submission handles for secondary sections
const handleCreateStore = (e: React.FormEvent) => {
e.preventDefault();
if (!newStore.name || !newStore.zone || !newStore.lead) {
alert('Kindly fill store metadata completely.');
return;
}
setStoresList([...storesList, {
locationid: 10000 + Math.floor(Math.random() * 9000),
name: newStore.name,
zone: newStore.zone,
deliveries: 0,
sales: '0 orders',
orders: 0,
staff: newStore.lead,
color: 'emerald',
status: 'Active'
}]);
setShowAddStoreModal(false);
setNewStore({ name: '', zone: '', lead: '', sales: '₹1,50,000' });
alert(`Node outlet "${newStore.name}" commissioned to live operations feed successfully.`);
};
// Calendar Event Modal state
const [showCalendarModal, setShowCalendarModal] = useState(false);
// The "Add Store" and "Scheduled Reports Calendar" modals that used to live
// here are gone, along with their state and `handleCreateStore`. Nothing ever
// opened them: `setShowAddStoreModal(true)` and `setShowCalendarModal(true)`
// were called nowhere in the codebase, so both were permanently false and the
// markup was unreachable. `handleCreateStore` was reachable only through one
// of them, and it never wrote to the backend — it pushed a row into local
// `storesList` with a random `locationid`, which the next refetch discarded.
// Real outlet creation is Settings → Outlets → Add Store Branch, which goes
// through AdminConsole and the createlocation endpoint.
// Callback action triggers
const handleHelp = () => {
alert('nearledaily User Manual & Documentation Center linked successfully. Contact Coimbatore regional IT hub desk for urgent escalations.');
notifyOk('Contact the Coimbatore regional IT hub desk for urgent escalations.');
};
const handleLogout = () => setAuthUser(null);
@@ -302,86 +275,65 @@ export default function App() {
{/* ── Search & Filter Toolbar ── */}
<div className="flex flex-col lg:flex-row items-center gap-3 bg-white p-1.5 rounded-2xl border border-slate-200/70 shadow-sm mb-8 w-full">
{/* Search Input */}
<div className="relative flex-1 w-full group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-purple-600 transition-colors">
<Search className="w-5 h-5" />
</div>
<input
type="text"
<div className="flex-1 w-full">
<TextInput
label="Search stores"
isLabelHidden
placeholder="Search store by name, zone, or manager..."
value={storesSearch}
onChange={(e) => setStoresSearch(e.target.value)}
className="w-full pl-11 pr-12 py-3 bg-transparent border-none text-sm font-semibold text-slate-800 placeholder-slate-400 focus:outline-none focus:ring-0 transition-all duration-300"
onChange={(v) => setStoresSearch(v)}
startIcon={<Search className="w-4 h-4" />}
hasClear
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
{storesSearch && (
<button
onClick={() => setStoresSearch('')}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Vertical Divider (Hidden on Mobile) */}
<div className="hidden lg:block w-px h-8 bg-slate-200" />
{/* Filter Segmented Control */}
<div className="flex items-center p-1 bg-slate-50/80 rounded-xl border border-slate-200/50 w-full lg:w-auto shrink-0">
<button
onClick={() => setStoresFilter('ALL')}
className={`flex-1 lg:flex-none px-5 py-2 rounded-lg text-xs font-bold transition-all duration-300 flex items-center justify-center gap-2 ${
storesFilter === 'ALL'
? 'bg-white text-slate-800 shadow-[0_1px_3px_rgba(0,0,0,0.1)] ring-1 ring-slate-900/5'
: 'text-slate-500 hover:text-slate-700 hover:bg-white/50'
}`}
{/* PHASE 6 — the three filter buttons were a hand-built segmented
control: they looked like one but announced as three unrelated
buttons with no active state. The counts move into the labels so
each option's accessible name carries its own number. */}
<div className="w-full lg:w-auto shrink-0">
<SegmentedControl
label="Filter stores by status"
size="sm"
value={storesFilter}
onChange={(v) => setStoresFilter(v as typeof storesFilter)}
>
All Nodes
<span className={`text-[10px] px-2 py-0.5 rounded-full font-bold ${storesFilter === 'ALL' ? 'bg-slate-100 text-slate-700' : 'bg-slate-200/50 text-slate-400'}`}>
{storesList.length}
</span>
</button>
<button
onClick={() => setStoresFilter('ACTIVE')}
className={`flex-1 lg:flex-none px-5 py-2 rounded-lg text-xs font-bold transition-all duration-300 flex items-center justify-center gap-2 ${
storesFilter === 'ACTIVE'
? 'bg-white text-emerald-600 shadow-[0_1px_3px_rgba(0,0,0,0.1)] ring-1 ring-emerald-500/20'
: 'text-slate-500 hover:text-emerald-600 hover:bg-emerald-50/50'
}`}
>
Active
<span className={`text-[10px] px-2 py-0.5 rounded-full font-bold ${storesFilter === 'ACTIVE' ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-200/50 text-slate-400'}`}>
{storesList.filter(s => s.status.toLowerCase() === 'active').length}
</span>
</button>
<button
onClick={() => setStoresFilter('INACTIVE')}
className={`flex-1 lg:flex-none px-5 py-2 rounded-lg text-xs font-bold transition-all duration-300 flex items-center justify-center gap-2 ${
storesFilter === 'INACTIVE'
? 'bg-white text-slate-700 shadow-[0_1px_3px_rgba(0,0,0,0.1)] ring-1 ring-slate-900/5'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-200/50'
}`}
>
Inactive
<span className={`text-[10px] px-2 py-0.5 rounded-full font-bold ${storesFilter === 'INACTIVE' ? 'bg-slate-100 text-slate-700' : 'bg-slate-200/50 text-slate-400'}`}>
{storesList.filter(s => s.status.toLowerCase() !== 'active').length}
</span>
</button>
<SegmentedControlItem value="ALL" label={`All Nodes (${storesList.length})`} />
<SegmentedControlItem
value="ACTIVE"
label={`Active (${storesList.filter(s => s.status.toLowerCase() === 'active').length})`}
/>
<SegmentedControlItem
value="INACTIVE"
label={`Inactive (${storesList.filter(s => s.status.toLowerCase() !== 'active').length})`}
/>
</SegmentedControl>
</div>
</div>
{/* Empty States */}
{filteredStoresList.length === 0 && (
<div className="text-center py-12 text-zinc-400 text-xs border border-dashed border-[#e2e8f0] rounded-xl bg-white p-8">
<div className="py-8">
{locationsQ.isLoading ? (
<div className="flex flex-col items-center justify-center gap-2">
<div className="w-6 h-6 border-2 border-[#662582] border-t-transparent rounded-full animate-spin" />
<span>Loading live store locations…</span>
</div>
<div className="flex justify-center"><Spinner label="Loading live store locations…" /></div>
) : (
<span>No store locations found matching your filter criteria.</span>
<EmptyState
icon={<Building size={26} />}
title="No store locations found"
description={
storesSearch || storesFilter !== 'ALL'
? 'Nothing matches your search or filter.'
: 'Commission your first outlet to start taking orders.'
}
actions={
storesSearch || storesFilter !== 'ALL'
? <Button label="Clear filters" variant="secondary" onClick={() => { setStoresSearch(''); setStoresFilter('ALL'); }} />
: undefined
}
/>
)}
</div>
)}
@@ -505,34 +457,41 @@ export default function App() {
</div>
</div>
<button
onClick={(e) => {
<IconButton
label={`Contact node lead ${st.staff}`}
variant="secondary"
size="sm"
icon={<Phone className="w-3.5 h-3.5" />}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
alert(`Routing communications channel directly to manager ${st.staff}...`);
notifyOk(`Routing communications channel directly to manager ${st.staff}…`);
}}
className="p-1.5 rounded-lg bg-white border border-zinc-200 hover:border-[#662582] hover:text-[#662582] text-zinc-500 transition-colors shadow-sm"
title="Communicate with Node Lead"
>
<Phone className="w-3.5 h-3.5" />
</button>
/>
</div>
{/* Card footer - enter console */}
<div className="flex items-center justify-between text-[10px] font-bold text-[#662582] mt-4 pt-3 border-t border-zinc-100/80">
<button
onClick={(e) => {
e.stopPropagation();
const newStatus = st.status.toLowerCase() === 'active' ? 'Inactive' : 'Active';
setStoresList(stores => stores.map(s => s.locationid === st.locationid ? { ...s, status: newStatus } : s));
if (st.locationid) {
updateLocation.mutate({ locationid: st.locationid, status: newStatus.toLowerCase() });
}
}}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${st.status.toLowerCase() === 'active' ? 'bg-emerald-500' : 'bg-zinc-300'}`}
title={`Toggle store to ${st.status.toLowerCase() === 'active' ? 'Inactive' : 'Active'}`}
>
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${st.status.toLowerCase() === 'active' ? 'translate-x-5' : 'translate-x-1'}`} />
</button>
{/* A real Switch. This was a <button> hand-drawn to look
like a toggle: it announced as a button with no on/off
state, and its only explanation was a `title`. Switch
exposes the state via a checkbox role, so the store's
live/inactive status is finally audible — on a control
that takes a shop offline. */}
<span onClick={(e) => e.stopPropagation()}>
<Switch
label={`${st.name} is ${st.status.toLowerCase() === 'active' ? 'active' : 'inactive'}`}
isLabelHidden
size="sm"
value={st.status.toLowerCase() === 'active'}
onChange={(checked) => {
const newStatus = checked ? 'Active' : 'Inactive';
setStoresList(stores => stores.map(s => s.locationid === st.locationid ? { ...s, status: newStatus } : s));
if (st.locationid) {
updateLocation.mutate({ locationid: st.locationid, status: newStatus.toLowerCase() });
}
}}
/>
</span>
<div className="flex items-center gap-1 uppercase tracking-wider">
<span>Enter Terminal Console</span>
@@ -709,138 +668,6 @@ export default function App() {
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
</Routes>
{/* CALENDAR SCHEDULER DIALOG MODAL */}
{showCalendarModal && (
<div
className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md"
onClick={(e) => { if (e.target === e.currentTarget) setShowCalendarModal(false); }}
>
<div className="bg-white border border-[#e2e8f0] rounded-xl w-full max-w-[24rem] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-xs font-sans cursor-default">
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0">
<h4 className="font-bold text-[#0f172a] flex items-center gap-xs">
<Calendar size={15} className="text-[#662582]" />
Scheduled Reports Calendar
</h4>
<button
onClick={() => setShowCalendarModal(false)}
className="p-1 hover:bg-zinc-200 rounded-full text-zinc-400 cursor-pointer transition-colors"
>
<X size={16} />
</button>
</div>
<div className="p-md space-y-md overflow-y-auto flex-1">
<p className="text-zinc-500 leading-relaxed font-semibold">
Automated compliance summaries will appear here once scheduled-report exports are available.
</p>
<AwaitingApi label="Scheduled reports" api="[R13]" />
</div>
<div className="p-sm bg-[#f8fafc] border-t border-[#e2e8f0] flex justify-end shrink-0">
<button
onClick={() => setShowCalendarModal(false)}
className="px-4 py-2 bg-[#0f172a] text-white rounded-lg font-semibold hover:bg-zinc-800 cursor-pointer shadow-sm transition-colors"
>
Close Calendar
</button>
</div>
</div>
</div>
)}
{/* CREATE NEW STORE MODAL */}
{showAddStoreModal && (
<div
className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md"
onClick={(e) => { if (e.target === e.currentTarget) setShowAddStoreModal(false); }}
>
<div className="bg-white border border-[#e2e8f0] rounded-xl w-full max-w-[24rem] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-xs font-sans cursor-default">
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0">
<h4 className="font-bold text-[#0f172a] flex items-center gap-xs">
<Building size={15} className="text-[#662582]" />
Commission New Regional Store Node
</h4>
<button
onClick={() => setShowAddStoreModal(false)}
className="p-1 hover:bg-zinc-200 rounded-full text-zinc-400 cursor-pointer transition-colors"
>
<X size={16} />
</button>
</div>
<form onSubmit={handleCreateStore} className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div className="p-md space-y-md overflow-y-auto flex-1">
<div className="space-y-sm">
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">STORE OUTLET NAME (*)</label>
<input
type="text"
placeholder="e.g. RS Puram Super Hub"
value={newStore.name}
onChange={(e) => setNewStore({ ...newStore, name: e.target.value })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]"
required
/>
</div>
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">LOCAL ZONE AREA (*)</label>
<input
type="text"
placeholder="e.g. Coimbatore North"
value={newStore.zone}
onChange={(e) => setNewStore({ ...newStore, zone: e.target.value })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]"
required
/>
</div>
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">OUTLET TEAM MANAGER (*)</label>
<input
type="text"
placeholder="e.g. Sridhar Sundaram"
value={newStore.lead}
onChange={(e) => setNewStore({ ...newStore, lead: e.target.value })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]"
required
/>
</div>
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">ESTIMATED INITIAL REVENUE</label>
<input
type="text"
placeholder="₹1,50,000"
value={newStore.sales}
onChange={(e) => setNewStore({ ...newStore, sales: e.target.value })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]"
/>
</div>
</div>
</div>
<div className="p-md border-t border-[#f1f5f9] flex justify-end gap-sm bg-[#f8fafc] shrink-0">
<button
type="button"
onClick={() => setShowAddStoreModal(false)}
className="px-4 py-2 border border-[#e2e8f0] rounded-lg font-semibold text-zinc-500 hover:bg-zinc-50 cursor-pointer"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-[#662582] text-white rounded-lg font-bold hover:bg-purple-800 cursor-pointer shadow-sm"
>
Create Outlet Node
</button>
</div>
</form>
</div>
</div>
)}
<ComparisonModal />
</>
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,202 +0,0 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { Users, Search, Phone, Mail, ChevronRight, Award, MapPin } from 'lucide-react';
import { useFiestaTenantCustomers, useFiestaTenantLocations } from '../services/fiestaQueries';
import { num as fnum, str as fstr, customerName, customerStoreId } from '../services/fiestaApi';
interface AdminCustomersViewProps {
tenantId: number;
}
export default function AdminCustomersView({ tenantId }: AdminCustomersViewProps) {
const [customerSearch, setCustomerSearch] = useState('');
// Fetch all customers for the tenant by passing locationid: 0
const customersQ = useFiestaTenantCustomers({
tenantid: tenantId,
locationid: 0,
pagesize: 500,
});
const locationsQ = useFiestaTenantLocations(tenantId);
const locations = locationsQ.data ?? [];
const getMergedCustomers = () => {
const rawCustomers = customersQ.data ?? [];
return rawCustomers.map((c: any) => {
const id =
fnum(c.customerid) ||
fnum(c.customerheaderid) ||
fnum(c.userid) ||
fnum(c.id) ||
null;
const spent = fnum(c.totalspent);
// `locationid` is not a column on this response — the store link is
// aliased `tenantlocationid`. Matching on the wrong name meant every
// customer fell through to the invented store name 'Central'.
const locId = customerStoreId(c);
const loc = locations.find((l: any) => fnum(l.locationid) === locId);
const storeName = (loc ? fstr(loc.locationname) : fstr(c.locationname)).trim() || '—';
return {
id,
name: customerName(c) || 'Customer',
phone: fstr(c.contactno) || '—',
email: fstr(c.email),
address: fstr(c.address) || '—',
ordersCount: Number(c.orderscount) || 0,
totalSpent: spent > 0 ? `₹${spent.toLocaleString('en-IN')}` : '—',
storeName,
};
});
};
const customersList = getMergedCustomers().filter(c =>
c.name.toLowerCase().includes(customerSearch.toLowerCase()) ||
c.phone.includes(customerSearch) ||
c.address.toLowerCase().includes(customerSearch.toLowerCase()) ||
c.storeName.toLowerCase().includes(customerSearch.toLowerCase())
);
// Helper for jewel-tone avatars
const toneFor = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
const hues = [
{ soft: '#f3e8ff', fg: '#6b21a8' }, // purple
{ soft: '#e0e7ff', fg: '#3730a3' }, // indigo
{ soft: '#fce7f3', fg: '#9d174d' }, // pink
{ soft: '#ffedd5', fg: '#9a3412' }, // orange
{ soft: '#dcfce7', fg: '#166534' }, // green
{ soft: '#cffafe', fg: '#155e75' }, // cyan
];
return hues[Math.abs(hash) % hues.length];
};
const initialsOf = (name: string) => {
const p = name.split(' ').filter(Boolean);
return ((p[0]?.[0] || '') + (p.length > 1 ? p[p.length - 1][0] : '')).toUpperCase();
};
const localityOf = (addr: string) => {
if (!addr) return '';
const p = addr.split(',');
return p.length > 1 ? p[p.length - 2].trim() : p[0].trim();
};
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row justify-between md:items-end gap-4">
<div>
<h2 className="text-2xl font-black text-[#0f172a] flex items-center gap-2">
<Users size={26} className="text-[#662582]" />
Customers
</h2>
</div>
<div className="relative w-full md:w-72 shrink-0">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#662582]" />
<input
type="text"
placeholder="Search customers, stores..."
value={customerSearch}
onChange={(e) => setCustomerSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 border border-[#662582]/20 rounded-xl text-[13px] text-[#0f172a] placeholder:text-zinc-400 outline-none bg-white focus:border-[#662582] focus:ring-4 focus:ring-[#662582]/10 transition-all shadow-sm"
/>
</div>
</div>
{customersQ.isLoading ? (
<div className="h-64 flex flex-col items-center justify-center space-y-4">
<div className="relative w-10 h-10">
<span className="absolute inset-0 rounded-full border-2 border-purple-100" />
<span className="absolute inset-0 rounded-full border-2 border-t-purple-600 animate-spin" />
</div>
<p className="text-xs font-bold text-zinc-400 uppercase tracking-widest">Loading Customers...</p>
</div>
) : customersList.length === 0 ? (
<div className="bg-white border border-[#e2e8f0] rounded-3xl py-24 flex flex-col items-center gap-4 text-center px-6 shadow-sm">
<span className="flex items-center justify-center w-16 h-16 rounded-2xl bg-purple-50 text-purple-700 shadow-sm"><Users size={28} /></span>
<p className="text-lg font-bold text-[#0f172a]">No customers found</p>
<p className="text-sm text-zinc-500 max-w-[350px] leading-relaxed">
{customerSearch ? 'Nothing matches your search criteria. Try a different term.' : 'There are no customers registered in the tenant yet.'}
</p>
{customerSearch && (
<button onClick={() => setCustomerSearch('')} className="mt-2 text-[13px] font-bold text-purple-700 bg-purple-50 hover:bg-purple-100 px-4 py-2 rounded-lg transition-colors cursor-pointer">
Clear Search
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{customersList.map((c: any, idx: number) => {
const tone = toneFor(c.name || `c${idx}`);
const locality = localityOf(c.address);
return (
<div key={c.id ?? idx} className="group bg-white border border-[#e2e8f0] rounded-2xl p-5 hover:shadow-lg hover:border-purple-300 transition-all duration-300 flex flex-col">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3 min-w-0">
<span
className="w-12 h-12 rounded-2xl flex items-center justify-center font-bold text-sm shrink-0 border border-white"
style={{ background: tone.soft, color: tone.fg }}
>
{initialsOf(c.name)}
</span>
<div className="min-w-0">
<h3 className="font-extrabold text-[#0f172a] text-[15px] truncate" title={c.name}>{c.name}</h3>
{locality && (
<p className="text-[11px] text-zinc-500 mt-0.5 flex items-center gap-1 font-medium truncate" title={locality}>
<MapPin size={10} className="shrink-0 text-zinc-400" /> {locality}
</p>
)}
</div>
</div>
{c.ordersCount > 5 && (
<span className="shrink-0 ml-2 px-2 py-1 bg-amber-50 text-amber-700 text-[9px] font-black uppercase tracking-widest rounded-lg border border-amber-200 flex items-center gap-1">
<Award size={10} /> Loyal
</span>
)}
</div>
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="bg-slate-50 rounded-xl p-3 border border-slate-100">
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest block">Orders</span>
<span className="font-black text-[16px] text-[#0f172a] mt-0.5 block">{c.ordersCount}</span>
</div>
<div className="bg-emerald-50/50 rounded-xl p-3 border border-emerald-100/50">
<span className="text-[9px] font-bold text-emerald-600/70 uppercase tracking-widest block">Gross Spend</span>
<span className="font-black text-[16px] text-emerald-700 mt-0.5 block">{c.totalSpent}</span>
</div>
</div>
<div className="space-y-2 mb-5 flex-1">
<div className="flex items-center gap-2 text-xs text-slate-600 font-medium">
<div className="w-6 h-6 rounded-lg bg-slate-100 flex items-center justify-center shrink-0"><Phone size={12} className="text-slate-400" /></div>
<span className="tabular-nums">{c.phone}</span>
</div>
{c.email && c.email !== '—' && (
<div className="flex items-center gap-2 text-xs text-slate-600 font-medium">
<div className="w-6 h-6 rounded-lg bg-slate-100 flex items-center justify-center shrink-0"><Mail size={12} className="text-slate-400" /></div>
<span className="truncate" title={c.email}>{c.email}</span>
</div>
)}
</div>
<div className="pt-4 border-t border-slate-100 flex items-center justify-between gap-3 mt-auto">
<span className="text-xs font-bold text-purple-700 bg-purple-50 px-3 py-1.5 rounded-lg truncate w-full text-center">
{c.storeName}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -14,7 +14,6 @@
*/
import React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
Truck, Clock, CheckCircle2, XCircle, Calendar, Sun, Sunset, Moon, Layers, UserCheck, MapPin, Phone, Package, Loader2, X, Bike,
} from 'lucide-react';
@@ -22,9 +21,22 @@ import { useFiestaDeliverySummary, useFiestaDeliveries, useFiestaRiders, useFies
import { FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, RIDER_MESSAGES, RiderNotReachableError, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { shortTime } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Dialog } from '@astryxdesign/core/Dialog';
import { DateInput } from '@astryxdesign/core/DateInput';
import type { ISODateString } from '@astryxdesign/core/utils';
import { Selector } from '@astryxdesign/core/Selector';
import { Heading } from '@astryxdesign/core/Heading';
import { Spinner } from '@astryxdesign/core/Spinner';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Card as ShellCard } from './consoleUi';
import {
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge,
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table';
import {
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar,
DELIVERY_STATUS, statusColor, BRAND, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge,
} from './consoleUi';
interface DeliveriesViewProps { searchQuery?: string; locationid?: number; tenantId?: number; applocationid?: number; date?: string; }
@@ -144,9 +156,25 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
))}
</div>
<div className="flex items-center gap-2 text-xs">
<input type="date" value={fromdate} max={todate} onChange={(e) => setFromdate(e.target.value)} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
{/* Both were unlabelled `<input type="date">`; the min/max
cross-guard between them is preserved. */}
<DateInput
label="From date"
isLabelHidden
size="sm"
value={fromdate as ISODateString}
max={todate as ISODateString}
onChange={(v) => { if (v) setFromdate(v); }}
/>
<span style={{ color: TEXT_3 }}>→</span>
<input type="date" value={todate} min={fromdate} onChange={(e) => setTodate(e.target.value)} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
<DateInput
label="To date"
isLabelHidden
size="sm"
value={todate as ISODateString}
min={fromdate as ISODateString}
onChange={(v) => { if (v) setTodate(v); }}
/>
</div>
</div>
</FilterBar>
@@ -166,17 +194,17 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
</FilterBar>
{/* Table */}
<div className="bg-white border overflow-hidden" style={{ borderColor: BORDER }}>
<ShellCard className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 1240 }}>
<thead>
<tr>{['S.No', 'Tenant', 'Order ID', 'Pickup', 'Delivery', 'Rider', 'KMS', 'Amount', 'Status', 'Notes', 'Action'].map((h, i) => (<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>))}</tr>
</thead>
<tbody>
<Table style={{ minWidth: 1240 }}>
<TableHeader>
<TableRow>{['S.No', 'Tenant', 'Order ID', 'Pickup', 'Delivery', 'Rider', 'KMS', 'Amount', 'Status', 'Notes', 'Action'].map((h, i) => (<TableHeaderCell key={i}>{h}</TableHeaderCell>))}</TableRow>
</TableHeader>
<TableBody>
{deliveriesQ.isLoading ? (
<tr><td colSpan={11} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}><span className="inline-flex items-center gap-2 text-xs font-semibold"><Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading deliveries…</span></td></tr>
<TableRow><TableCell colSpan={11}><div className="py-8 flex justify-center"><Spinner label="Loading deliveries…" /></div></TableCell></TableRow>
) : rows.length === 0 ? (
<tr><td colSpan={11} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No {status} deliveries in this wave. Try another status, wave, or date.</td></tr>
<TableRow><TableCell colSpan={11}><EmptyState isCompact icon={<Truck size={26} />} title={`No ${status} deliveries in this wave`} description="Try another status, wave, or date." /></TableCell></TableRow>
) : (
rows.map((r, i) => {
const st = fstr(r.orderstatus).toLowerCase();
@@ -192,64 +220,65 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
const dropAddr = fstr(r.deliveryaddress) || fstr(r.deliverylocation) || fstr(r.deliverysuburb);
const notes = fstr(r.ordernotes) || fstr(r.notes);
return (
<tr key={fstr(r.deliveryid) || fstr(r.orderid) || i} className="transition-colors align-top" style={{ borderBottom: `1px solid ${DIVIDER}` }}
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td>
<td className="px-3 py-2.5">
<TableRow key={fstr(r.deliveryid) || fstr(r.orderid) || i}>
<TableCell><span className="font-mono" style={{ color: TEXT_3 }}>{i + 1}</span></TableCell>
<TableCell>
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{tenant || '—'}</p>
{fstr(r.tenantcity) && <p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.tenantcity)}</p>}
</td>
<td className="px-3 py-2.5">
</TableCell>
<TableCell>
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.assigntime || r.deliverydate)}</p>
</td>
<td className="px-3 py-2.5">
</TableCell>
<TableCell>
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{pickupName || pickupAddr || '—'}</p>
<p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{pickupName ? pickupAddr : ''}</p>
</td>
<td className="px-3 py-2.5">
</TableCell>
<TableCell>
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{dropName || dropAddr || '—'}</p>
<p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{dropName ? dropAddr : ''}</p>
</td>
<td className="px-3 py-2.5">
</TableCell>
<TableCell>
{rider ? (
<span className="inline-flex items-center gap-2">
<span className="rounded-full flex items-center justify-center shrink-0" style={{ width: 26, height: 26, background: soft('#8b5cf6'), color: '#8b5cf6' }}><Bike size={13} /></span>
<span className="font-bold text-[12px] truncate max-w-[100px]" style={{ color: TEXT }}>{rider}</span>
</span>
) : <span className="text-[11px] italic" style={{ color: TEXT_3 }}>Unassigned</span>}
</td>
<td className="px-3 py-2.5">
</TableCell>
<TableCell>
<div className="flex flex-col items-start gap-1">
<MetricPill color="#ef4444" minWidth={64}>{kms ? kms.toFixed(1) : '—'}</MetricPill>
{actualKms > 0 && <MetricPill color="#10b981" minWidth={64}>{actualKms.toFixed(1)}</MetricPill>}
</div>
</td>
<td className="px-3 py-2.5">
</TableCell>
<TableCell>
<div className="flex flex-col items-start gap-1">
{charge > 0 && <MetricPill color="#ef4444" minWidth={72}>₹{charge.toLocaleString('en-IN')}</MetricPill>}
{amt > 0 && <MetricPill color="#10b981" minWidth={72}>₹{amt.toLocaleString('en-IN')}</MetricPill>}
{charge === 0 && amt === 0 && <span style={{ color: TEXT_3 }}>—</span>}
</div>
</td>
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} /></td>
<td className="px-3 py-2.5">
</TableCell>
<TableCell><StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} /></TableCell>
<TableCell>
<p className="text-[11px] truncate max-w-[160px]" style={{ color: notes ? TEXT_2 : TEXT_3 }} title={notes}>{notes || '—'}</p>
</td>
<td className="px-3 py-2.5 text-right">
<button onClick={() => setDetailRow(r)} className="rounded-full font-extrabold cursor-pointer" style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>View</button>
</td>
</tr>
</TableCell>
<TableCell>
<div className="text-right">
<Button label="View" aria-label={`View delivery ${fstr(r.orderid) || fstr(r.deliveryid)}`} variant="secondary" size="sm" onClick={() => setDetailRow(r)} />
</div>
</TableCell>
</TableRow>
);
})
)}
</tbody>
</table>
</TableBody>
</Table>
</div>
<div className="px-4 py-2.5 border-t text-[10px] font-bold uppercase tracking-wider" style={{ borderColor: BORDER, background: SURFACE_ALT, color: TEXT_2 }}>
{rows.length} {status} · {BATCHES.find((b) => b.id === batch)?.label} wave
</div>
</div>
</ShellCard>
{detailRow && <DeliveryDetailModal row={detailRow} riders={ridersQ.data ?? []} onClose={() => setDetailRow(null)} />}
</div>
@@ -329,24 +358,27 @@ function RiderActions({ row, riders }: { row: Row; riders: Row[] }) {
{canChange ? (
<div className="flex flex-wrap items-center gap-2">
<select
value={picked}
onChange={(e) => setPicked(Number(e.target.value))}
disabled={busy}
className="rounded-full font-bold text-[11px] outline-none cursor-pointer disabled:opacity-50"
style={{ padding: '6px 10px', border: `1px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 200 }}
>
<option value={0}>{options.length ? 'Change rider…' : 'No riders on duty'}</option>
{options.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<button
{/* The "no riders on duty" wording carried real information, so it
moves to `placeholder` rather than being flattened away. */}
<div className="min-w-[180px] max-w-[220px]">
<Selector
label="Change rider"
isLabelHidden
size="sm"
value={picked ? String(picked) : ''}
onChange={(v) => setPicked(Number(v) || 0)}
isDisabled={busy}
placeholder={options.length ? 'Change rider…' : 'No riders on duty'}
options={options.map((o) => ({ value: String(o.id), label: o.label }))}
/>
</div>
<Button
label={changeMut.isPending ? 'Moving…' : 'Change'}
size="sm"
isLoading={changeMut.isPending}
isDisabled={!picked || busy}
onClick={handleChange}
disabled={!picked || busy}
className="rounded-full font-extrabold cursor-pointer text-white disabled:opacity-40 disabled:cursor-not-allowed"
style={{ padding: '6px 14px', fontSize: 11, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}
>
{changeMut.isPending ? 'Moving…' : 'Change'}
</button>
/>
</div>
) : (
<p className="text-[11px] font-medium" style={{ color: TEXT_3 }}>
@@ -358,25 +390,27 @@ function RiderActions({ row, riders }: { row: Row; riders: Row[] }) {
{canNotify && (
<div className="flex flex-wrap items-center gap-2 mt-2 pt-2" style={{ borderTop: `1px solid ${DIVIDER}` }}>
<button
<Button
label="Notify rider"
variant="secondary"
size="sm"
isDisabled={busy}
onClick={() => push(currentToken, RIDER_MESSAGES.reminder)}
disabled={busy}
className="rounded-full font-bold cursor-pointer disabled:opacity-40"
style={{ padding: '5px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}
>
Notify rider
</button>
<button
onClick={() =>
push(currentToken, RIDER_MESSAGES.cancelled(fstr(row.orderid) || `DLV-${deliveryid}`), { type: 'cancel' }, 'Cancellation sent')
}
disabled={busy}
title="Tells the rider app to drop this delivery"
className="rounded-full font-bold cursor-pointer disabled:opacity-40"
style={{ padding: '5px 12px', fontSize: 11, color: '#b91c1c', background: '#fef2f2', border: '1px solid #fecaca' }}
>
Send cancellation
</button>
/>
{/* Destructive: this tells the rider app to drop the delivery. It was
styled red but rendered as an ordinary button, and its only
explanation was a `title` — which a disabled control never shows. */}
<span title="Tells the rider app to drop this delivery">
<Button
label="Send cancellation"
variant="destructive"
size="sm"
isDisabled={busy}
onClick={() =>
push(currentToken, RIDER_MESSAGES.cancelled(fstr(row.orderid) || `DLV-${deliveryid}`), { type: 'cancel' }, 'Cancellation sent')
}
/>
</span>
</div>
)}
@@ -401,15 +435,21 @@ function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]
{ label: 'Picked', field: 'pickuptime' }, { label: 'Delivered', field: 'deliverytime' },
];
// Portal to <body> so `fixed inset-0` is viewport-relative even when an ancestor
// in the view tree is transformed/blurred (otherwise the panel collapses).
return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="bg-white max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
<div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)` }} />
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Truck size={16} style={{ color: BRAND }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}</h4>
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button>
// PHASE 6 — the portal and the `width: min(32rem, 92vw)` are gone with the
// overlay. Both existed for one reason: `fixed inset-0` resolves against the
// nearest transformed/filtered ancestor rather than the viewport, and this
// page sits inside several, so the panel used to collapse. Dialog renders in
// the browser's top layer, which no ancestor can contain.
return (
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
<div className="flex flex-col max-h-[90vh]">
<div className="p-4 flex justify-between items-center shrink-0">
<Heading level={3}>
<span className="flex items-center gap-2">
<Truck size={16} style={{ color: BRAND }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}
</span>
</Heading>
<IconButton label="Close delivery details" variant="ghost" size="sm" icon={<X size={16} />} onClick={onClose} />
</div>
<div className="p-4 space-y-4 overflow-y-auto flex-1">
<div className="flex items-center justify-between">
@@ -451,11 +491,10 @@ function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]
</div>
<RiderActions row={row} riders={riders} />
</div>
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}>Close</button>
<div className="p-3 flex justify-end shrink-0">
<Button label="Close" onClick={onClose} />
</div>
</div>
</div>,
document.body,
</Dialog>
);
}

View File

@@ -25,6 +25,15 @@ import {
} from './consoleUi';
import SalesRevenueReport from './SalesRevenueReport';
import { DateInput } from '@astryxdesign/core/DateInput';
import type { ISODateString } from '@astryxdesign/core/utils';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { Badge } from '@astryxdesign/core/Badge';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Card as ShellCard } from './consoleUi';
import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table';
type ReportTab = 'orders-summary' | 'riders-summary';
const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> = [
@@ -55,19 +64,17 @@ export default function DeliveryReportsView({ searchQuery = '', tenantId = FIEST
return (
<div className="animate-in fade-in duration-300">
<div className="flex items-center gap-4 border-b border-slate-200/70 pb-2 mb-4">
<button
onClick={() => setActiveTab('overview')}
className={`pb-2 text-sm font-bold border-b-2 transition-colors ${activeTab === 'overview' ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
{/* PHASE 6 — two underline-styled buttons standing in for a tab bar; the
active one was marked by a border colour and nothing else. */}
<div className="border-b border-slate-200/70 pb-2 mb-4">
<SegmentedControl
label="Report section"
value={activeTab}
onChange={(v) => setActiveTab(v as typeof activeTab)}
>
Overview
</button>
<button
onClick={() => setActiveTab('sales_revenue')}
className={`pb-2 text-sm font-bold border-b-2 transition-colors ${activeTab === 'sales_revenue' ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
>
Sales & Revenue
</button>
<SegmentedControlItem value="overview" label="Overview" />
<SegmentedControlItem value="sales_revenue" label="Sales &amp; Revenue" />
</SegmentedControl>
</div>
{activeTab === 'overview' ? (
@@ -93,10 +100,28 @@ export default function DeliveryReportsView({ searchQuery = '', tenantId = FIEST
<React.Fragment key={p.key}><Pill active={activePreset === p.key} color={BRAND} onClick={() => { setFromdate(p.from); setTodate(p.to); }}>{p.label}</Pill></React.Fragment>
))}
</div>
{/* Both were unlabelled `<input type="date">` — a screen reader
announced "date" twice with no way to tell the ends of the range
apart. The min/max cross-guard is preserved. */}
<div className="flex items-center gap-2 text-xs">
<input type="date" value={fromdate} max={todate} onChange={(e) => setFromdate(e.target.value)} className="rounded-full outline-none font-semibold w-28 sm:w-auto" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
<DateInput
label="From date"
isLabelHidden
size="sm"
value={fromdate as ISODateString}
max={todate as ISODateString}
onChange={(v) => { if (v) setFromdate(v); }}
/>
<span style={{ color: TEXT_3 }}>→</span>
<input type="date" value={todate} min={fromdate} max={ymd(today)} onChange={(e) => setTodate(e.target.value)} className="rounded-full outline-none font-semibold w-28 sm:w-auto" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
<DateInput
label="To date"
isLabelHidden
size="sm"
value={todate as ISODateString}
min={fromdate as ISODateString}
max={ymd(today) as ISODateString}
onChange={(v) => { if (v) setTodate(v); }}
/>
</div>
</div>
</div>
@@ -202,8 +227,21 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
return (
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
{/* Revenue */}
<button onClick={() => setMetric('revenue')} className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${metric === 'revenue' ? 'border-indigo-200 bg-white shadow-[0_8px_24px_rgba(99,102,241,0.12)] ring-1 ring-indigo-500/20 z-10' : 'border-slate-200/70 bg-white hover:border-indigo-200 hover:shadow-[0_8px_24px_rgba(99,102,241,0.06)] opacity-70 hover:opacity-100'}`}>
{/* Revenue.
These two stay native <button> — a deliberate exception, and the only
one on this page. They are large stat tiles carrying a metric value,
a tooltip and an icon; Astryx's ToggleButton is Button-shaped and
would fight the layout, and SelectableCard was ruled out in PosView
because it renders no role and no aria-checked and strips both if you
pass them. What was actually missing is `aria-pressed`: the selected
metric lived only in a border colour. That is added here, which is
exactly what ToggleButton emits internally. */}
<button
onClick={() => setMetric('revenue')}
aria-pressed={metric === 'revenue'}
aria-label="Chart by revenue"
className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${metric === 'revenue' ? 'border-indigo-200 bg-white shadow-[0_8px_24px_rgba(99,102,241,0.12)] ring-1 ring-indigo-500/20 z-10' : 'border-slate-200/70 bg-white hover:border-indigo-200 hover:shadow-[0_8px_24px_rgba(99,102,241,0.06)] opacity-70 hover:opacity-100'}`}
>
<div className="flex justify-between items-start w-full">
<Tooltip content="Total revenue collected across all orders (excluding cancelled) in this period." position="bottom">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Revenue</span>
@@ -221,7 +259,12 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
</button>
{/* Orders */}
<button onClick={() => setMetric('orders')} className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${metric === 'orders' ? 'border-sky-200 bg-white shadow-[0_8px_24px_rgba(14,165,233,0.12)] ring-1 ring-sky-500/20 z-10' : 'border-slate-200/70 bg-white hover:border-sky-200 hover:shadow-[0_8px_24px_rgba(14,165,233,0.06)] opacity-70 hover:opacity-100'}`}>
<button
onClick={() => setMetric('orders')}
aria-pressed={metric === 'orders'}
aria-label="Chart by order count"
className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${metric === 'orders' ? 'border-sky-200 bg-white shadow-[0_8px_24px_rgba(14,165,233,0.12)] ring-1 ring-sky-500/20 z-10' : 'border-slate-200/70 bg-white hover:border-sky-200 hover:shadow-[0_8px_24px_rgba(14,165,233,0.06)] opacity-70 hover:opacity-100'}`}
>
<div className="flex justify-between items-start w-full">
<Tooltip content="Total number of orders placed in this period, regardless of current status." position="bottom">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Total Orders</span>
@@ -311,53 +354,61 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
</div>
</div>
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<ShellCard className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<tr>
<th className="px-5 py-4 w-12">#</th>
<th className="px-5 py-4">Outlet</th>
<th className="px-5 py-4 text-right">Revenue</th>
<th className="px-5 py-4 text-right">AOV</th>
<th className="px-5 py-4 text-right">Orders</th>
<th className="px-5 py-4 text-right">Fulfillment</th>
<th className="px-5 py-4 text-right">Cancel Rate</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{q.isLoading || revenueQ.isLoading ? <tr><td colSpan={7} className="px-5 py-6"><div className="space-y-3"><Skeleton className="h-5 w-full" /><Skeleton className="h-5 w-full opacity-70" /><Skeleton className="h-5 w-full opacity-40" /></div></td></tr>
: rows.length === 0 ? <tr><td colSpan={7} className="px-5 py-12 text-center text-slate-500 font-medium">No outlet data available.</td></tr>
: rows.map((r, i) => {
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHeaderCell>#</TableHeaderCell>
<TableHeaderCell>Outlet</TableHeaderCell>
<TableHeaderCell>Revenue</TableHeaderCell>
<TableHeaderCell>AOV</TableHeaderCell>
<TableHeaderCell>Orders</TableHeaderCell>
<TableHeaderCell>Fulfillment</TableHeaderCell>
<TableHeaderCell>Cancel Rate</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{q.isLoading || revenueQ.isLoading ? (
<TableRow><TableCell colSpan={7}>
<div className="space-y-3 py-3"><Skeleton className="h-5 w-full" /><Skeleton className="h-5 w-full opacity-70" /><Skeleton className="h-5 w-full opacity-40" /></div>
</TableCell></TableRow>
) : rows.length === 0 ? (
<TableRow><TableCell colSpan={7}>
<EmptyState isCompact icon={<Store size={26} />} title="No outlet data available" description="Nothing recorded for this period." />
</TableCell></TableRow>
) : rows.map((r, i) => {
const rowRev = locationRevenueMap.get(fnum(r.locationid)) ?? 0;
const rowAov = r.total > 0 ? rowRev / r.total : 0;
const rowFulfill = r.total > 0 ? (r.delivered / r.total) * 100 : 0;
const rowCancel = r.total > 0 ? (r.cancelled / r.total) * 100 : 0;
return (
<tr key={r.locationid || i} className="hover:bg-slate-50 transition-colors">
<td className="px-5 py-3 text-slate-400 font-medium">{i + 1}</td>
<td className="px-5 py-3 font-semibold text-slate-900">{r.locationname || `Location ${r.locationid}`}</td>
<td className="px-5 py-3 text-right font-bold text-indigo-600">₹{rowRev.toLocaleString('en-IN')}</td>
<td className="px-5 py-3 text-right text-slate-600 font-medium">₹{Math.round(rowAov).toLocaleString('en-IN')}</td>
<td className="px-5 py-3 text-right font-bold text-slate-900">{r.total.toLocaleString('en-IN')}</td>
<td className="px-5 py-3 text-right">
<TableRow key={r.locationid || i}>
<TableCell><span className="text-slate-400 font-medium">{i + 1}</span></TableCell>
<TableCell><span className="font-semibold">{r.locationname || `Location ${r.locationid}`}</span></TableCell>
<TableCell><div className="text-right font-bold text-indigo-600">₹{rowRev.toLocaleString('en-IN')}</div></TableCell>
<TableCell><div className="text-right font-medium">₹{Math.round(rowAov).toLocaleString('en-IN')}</div></TableCell>
<TableCell><div className="text-right font-bold">{r.total.toLocaleString('en-IN')}</div></TableCell>
<TableCell>
<div className="flex items-center justify-end gap-2">
<span className="text-xs font-bold text-emerald-600">{rowFulfill.toFixed(0)}%</span>
<div className="w-16 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-emerald-500 rounded-full" style={{ width: `${rowFulfill}%` }} />
</div>
</div>
</td>
<td className="px-5 py-3 text-right">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold ${rowCancel > 10 ? 'bg-rose-100 text-rose-700' : 'bg-slate-100 text-slate-600'}`}>
{rowCancel.toFixed(1)}%
</span>
</td>
</tr>
</TableCell>
<TableCell>
{/* Over 10% was rose, otherwise grey — that is a
threshold, so it reads as error vs neutral. */}
<div className="text-right">
<Badge variant={rowCancel > 10 ? 'error' : 'neutral'} label={`${rowCancel.toFixed(1)}%`} />
</div>
</TableCell>
</TableRow>
);
})}
</tbody>
</table>
</TableBody>
</Table>
</div>
{rows.length > 0 && (
<div className="bg-slate-50 border-t border-slate-200 px-5 py-3 flex items-center gap-4 text-sm font-medium">
@@ -368,7 +419,7 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
<span className="text-amber-600 font-bold">{totals.pending} pending</span>
</div>
)}
</div>
</ShellCard>
{/* Insights Bento */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">

View File

@@ -4,7 +4,7 @@
*/
import React, { useState, useMemo } from 'react';
import { Route, ShoppingBag, Truck, MapPin, Calendar, ChevronLeft, ChevronRight, Store, Users } from 'lucide-react';
import { Route, ShoppingBag, Truck, MapPin, ChevronLeft, ChevronRight, Store, Users } from 'lucide-react';
import DispatchView from './DispatchView';
import OrdersView from './OrdersView';
import DeliveriesView from './DeliveriesView';
@@ -12,14 +12,17 @@ import { useFiestaDeliveries, useFiestaTenantLocations } from '../services/fiest
import { FIESTA_TENANT_ID, ymd, num as fnum, str as fstr } from '../services/fiestaApi';
import type { Row } from '../services/fiestaApi';
import './DispatchView.css'; // For #hdr and date-chip styles
import { IconButton } from '@astryxdesign/core/IconButton';
import { DateInput } from '@astryxdesign/core/DateInput';
import type { ISODateString } from '@astryxdesign/core/utils';
import { Badge } from '@astryxdesign/core/Badge';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
interface DispatchHubViewProps {
locationid?: number;
tenantId?: number;
}
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_ID }: DispatchHubViewProps) {
const [activeTab, setActiveTab] = useState<'map' | 'orders' | 'deliveries'>('map');
@@ -55,8 +58,6 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
}, [locationsQ.data, locationid]);
const isToday = date === ymd(today);
const dateObj = new Date(`${date}T00:00:00`);
const prettyDate = `${WEEKDAYS[dateObj.getDay()]}, ${dateObj.getDate()} ${MONTHS[dateObj.getMonth()]}`;
const shiftDate = (delta: number) => {
const d = new Date(`${date}T00:00:00`);
d.setDate(d.getDate() + delta);
@@ -103,86 +104,75 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
</span>
)}
<div className={`flex items-center bg-slate-50 border border-slate-200 rounded-xl p-0.5 sm:p-1 shadow-sm ${isToday ? 'ring-1 ring-blue-500/20' : ''}`}>
<button className="p-1 sm:p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-200/50 transition-colors" onClick={() => shiftDate(-1)} title="Previous day">
<ChevronLeft size={16} />
</button>
<div className="relative flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-0.5 sm:py-1">
<Calendar size={13} className="text-slate-400 hidden sm:block" />
<div className="flex flex-col">
<span className="text-[8px] sm:text-[9px] font-bold text-slate-400 uppercase tracking-widest leading-none mb-0.5 flex items-center gap-1">
Date {isToday && <span className="text-[7px] sm:text-[8px] bg-blue-100 text-blue-600 px-1 rounded-sm">TODAY</span>}
</span>
<span className="text-[11px] sm:text-xs font-bold text-slate-700 leading-none">{prettyDate}</span>
</div>
<input
type="date"
value={date}
max={ymd(today)}
onChange={(e) => setDate(e.target.value)}
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
aria-label="Pick date"
{/* PHASE 6 — the date stepper.
The picker used to be an `opacity-0` <input type="date"> stretched
over a custom display, so the same date was rendered twice: once
as `prettyDate` for the eye and once inside an invisible control.
DateInput formats and announces it itself, so the duplicate
display goes and the TODAY marker becomes a Badge beside it.
The two chevrons had only a `title` — no accessible name. */}
<div className="flex items-center gap-1.5">
<IconButton
label="Previous day"
variant="ghost"
size="sm"
icon={<ChevronLeft size={16} />}
onClick={() => shiftDate(-1)}
/>
<div className="w-40 sm:w-44">
<DateInput
label="Dispatch date"
isLabelHidden
size="sm"
value={date as ISODateString}
max={ymd(today) as ISODateString}
onChange={(v) => { if (v) setDate(v); }}
/>
</div>
<button className="p-1 sm:p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-200/50 transition-colors disabled:opacity-30 disabled:hover:bg-transparent" onClick={() => shiftDate(1)} disabled={isToday} title="Next day">
<ChevronRight size={16} />
</button>
{isToday && <Badge variant="info" label="Today" />}
<IconButton
label="Next day"
variant="ghost"
size="sm"
icon={<ChevronRight size={16} />}
isDisabled={isToday}
onClick={() => shiftDate(1)}
/>
</div>
</div>
</div>
{/* ── Unified Tab Row ── */}
<div className="flex items-center px-3 sm:px-6 py-2.5 border-b border-slate-200 shadow-sm z-10 relative overflow-x-auto custom-scrollbar no-scrollbar touch-scrolling">
<div className="flex items-center p-1 bg-slate-100/80 rounded-lg border border-slate-200/80 shadow-inner shrink-0">
<button
onClick={() => setActiveTab('map')}
className={`flex items-center gap-1.5 px-3 sm:px-4 py-1 sm:py-1.5 text-[10px] sm:text-[11px] font-extrabold rounded-md transition-all duration-300 uppercase tracking-wide ${
activeTab === 'map' ? 'bg-white text-purple-700 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
}`}
{/* Both rows were hand-built segmented controls — three and two
buttons styled to look like one control, announcing as unrelated
buttons with the selection carried only in background colour. */}
<div className="shrink-0">
<SegmentedControl
label="Dispatch view"
size="sm"
value={activeTab}
onChange={(v) => setActiveTab(v as typeof activeTab)}
>
<Route size={13} className={activeTab === 'map' ? 'text-purple-600' : 'text-slate-400'} /> Map
</button>
<button
onClick={() => setActiveTab('orders')}
className={`flex items-center gap-1.5 px-3 sm:px-4 py-1 sm:py-1.5 text-[10px] sm:text-[11px] font-extrabold rounded-md transition-all duration-300 uppercase tracking-wide ${
activeTab === 'orders' ? 'bg-white text-purple-700 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
}`}
>
<ShoppingBag size={13} className={activeTab === 'orders' ? 'text-purple-600' : 'text-slate-400'} /> Orders
</button>
<button
onClick={() => setActiveTab('deliveries')}
className={`flex items-center gap-1.5 px-3 sm:px-4 py-1 sm:py-1.5 text-[10px] sm:text-[11px] font-extrabold rounded-md transition-all duration-300 uppercase tracking-wide ${
activeTab === 'deliveries' ? 'bg-white text-purple-700 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
}`}
>
<Truck size={13} className={activeTab === 'deliveries' ? 'text-purple-600' : 'text-slate-400'} /> Deliveries
</button>
<SegmentedControlItem value="map" label="Map" icon={<Route size={13} />} />
<SegmentedControlItem value="orders" label="Orders" icon={<ShoppingBag size={13} />} />
<SegmentedControlItem value="deliveries" label="Deliveries" icon={<Truck size={13} />} />
</SegmentedControl>
</div>
{/* Sub-tabs for Map View — only meaningful on the Map tab; they used to
stay visible (and inert) on Orders and Deliveries. */}
{activeTab === 'map' && (
<div className="flex items-center p-1 ml-3 sm:ml-4 bg-slate-100/80 rounded-lg border border-slate-200/80 shadow-inner shrink-0">
<button
onClick={() => setMapViewMode('stores')}
className={`flex items-center gap-1.5 px-3 sm:px-4 py-1 sm:py-1.5 text-[10px] sm:text-[11px] font-extrabold rounded-md transition-all duration-300 tracking-wide ${
mapViewMode === 'stores' ? 'bg-white text-slate-800 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
}`}
<div className="ml-3 sm:ml-4 shrink-0">
<SegmentedControl
label="Map grouping"
size="sm"
value={mapViewMode}
onChange={(v) => setMapViewMode(v as typeof mapViewMode)}
>
<Store size={13} className={mapViewMode === 'stores' ? 'text-blue-500' : 'text-slate-400'} /> By Store
</button>
<button
onClick={() => setMapViewMode('customers')}
className={`flex items-center gap-1.5 px-3 sm:px-4 py-1 sm:py-1.5 text-[10px] sm:text-[11px] font-extrabold rounded-md transition-all duration-300 tracking-wide ${
mapViewMode === 'customers' ? 'bg-white text-slate-800 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
}`}
>
<Users size={13} className={mapViewMode === 'customers' ? 'text-blue-500' : 'text-slate-400'} /> By Customer
</button>
<SegmentedControlItem value="stores" label="By Store" icon={<Store size={13} />} />
<SegmentedControlItem value="customers" label="By Customer" icon={<Users size={13} />} />
</SegmentedControl>
</div>
)}
</div>

View File

@@ -4,39 +4,20 @@
*/
import React, { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Layers,
Search,
Plus,
RefreshCw,
AlertTriangle,
TrendingUp,
Sparkles,
Check,
Package,
ChevronRight,
TrendingDown,
Trash2,
PackageCheck,
ShieldCheck,
Tag,
UploadCloud,
FileSpreadsheet,
Palette,
Info,
X,
Server,
ChevronDown,
ChevronUp,
CheckCircle,
ShoppingCart,
Inbox,
Store,
Activity,
Award,
Filter,
Box,
ArrowLeft
} from 'lucide-react';
import { ProductMatrixItem } from '../types';
@@ -55,7 +36,21 @@ import { useStoreCatalogue, usePriceEverywhere, isPublishedItem } from '../servi
import { CardCarousel, ProductGallery } from './CatalogueGallery';
import BulkCartDrawer from './BulkCartDrawer';
import AwaitingApi from './AwaitingApi';
import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi';
import { SlideDrawer, Skeleton, Card as ShellCard } from './consoleUi';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { TextInput } from '@astryxdesign/core/TextInput';
import { NumberInput } from '@astryxdesign/core/NumberInput';
import { Selector } from '@astryxdesign/core/Selector';
import { DateInput } from '@astryxdesign/core/DateInput';
import type { ISODateString } from '@astryxdesign/core/utils';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Badge } from '@astryxdesign/core/Badge';
import { Heading } from '@astryxdesign/core/Heading';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table';
import FMCGHoverOverlay from './FMCGHoverOverlay';
import CatalogueDetailPanel from './CatalogueDetailPanel';
import { useNotify } from './Toasts';
@@ -86,7 +81,6 @@ export default function InventoryView({
const [searchTerm, setSearchTerm] = useState('');
const [showCatalogueModal, setShowCatalogueModal] = useState(false);
const [showOfflineSales, setShowOfflineSales] = useState(false);
const navigate = useNavigate();
// ── Live stock across every outlet (Fiesta) ───────────────────────────────
// This page is the admin's command surface. The GLOBAL CATALOG is the deduped
@@ -432,48 +426,46 @@ export default function InventoryView({
<div className="mb-2 z-40 relative">
{/* Small card metrics grid */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
{/* PHASE 6 — four `bg-white` slabs onto Astryx Cards. The icon chips
keep their hues: those are category identity, not surface. */}
{/* Card 1: Total SKUs */}
<div className="bg-white rounded-lg p-2.5 px-3 border border-slate-200 shadow-sm hover:border-purple-200 transition-all group">
<ShellCard className="p-2.5 px-3 hover:border-purple-200 transition-all group">
<div className="flex justify-between items-start">
<span className="text-[9px] text-slate-500 uppercase tracking-widest font-bold">Total SKUs</span>
<span className="text-[9px] uppercase tracking-widest font-bold opacity-60">Total SKUs</span>
<div className="p-1 rounded-md bg-purple-50 text-purple-600 group-hover:scale-110 transition-transform">
<Package className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-0.5">
<h3 className="text-base font-extrabold tracking-tight font-mono text-slate-900">
{products.length}
</h3>
<p className="text-[9px] text-slate-400 font-semibold mt-0.5">Master catalogue</p>
<h3 className="text-base font-extrabold tracking-tight font-mono">{products.length}</h3>
<p className="text-[9px] font-semibold mt-0.5 opacity-50">Master catalogue</p>
</div>
</div>
</ShellCard>
{/* Card 2: Synced Outlets */}
<div className="bg-white rounded-lg p-2.5 px-3 border border-slate-200 shadow-sm hover:border-indigo-200 transition-all group">
<ShellCard className="p-2.5 px-3 hover:border-indigo-200 transition-all group">
<div className="flex justify-between items-start">
<span className="text-[9px] text-slate-500 uppercase tracking-widest font-bold">Active Outlets</span>
<span className="text-[9px] uppercase tracking-widest font-bold opacity-60">Active Outlets</span>
<div className="p-1 rounded-md bg-indigo-50 text-indigo-600 group-hover:scale-110 transition-transform">
<Layers className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-0.5">
<h3 className="text-base font-extrabold tracking-tight font-mono text-slate-900">
{locations.length}
</h3>
<p className="text-[9px] text-slate-400 font-semibold mt-0.5">Synced locations</p>
<h3 className="text-base font-extrabold tracking-tight font-mono">{locations.length}</h3>
<p className="text-[9px] font-semibold mt-0.5 opacity-50">Synced locations</p>
</div>
</div>
</ShellCard>
{/* Card 3: Total On-Hand Volume */}
<div className="bg-white rounded-lg p-2.5 px-3 border border-slate-200 shadow-sm hover:border-emerald-200 transition-all group">
<ShellCard className="p-2.5 px-3 hover:border-emerald-200 transition-all group">
<div className="flex justify-between items-start">
<span className="text-[9px] text-slate-500 uppercase tracking-widest font-bold">Total Stock</span>
<span className="text-[9px] uppercase tracking-widest font-bold opacity-60">Total Stock</span>
<div className="p-1 rounded-md bg-emerald-50 text-emerald-600 group-hover:scale-110 transition-transform">
<Check className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-0.5">
<h3 className="text-base font-extrabold tracking-tight font-mono text-slate-900">
<h3 className="text-base font-extrabold tracking-tight font-mono">
{storesStock.reduce((total, store) => {
return total + (store.rows || []).reduce((subTotal, r) => {
const inv = stockRowToInventory(r, store.locationname);
@@ -481,25 +473,25 @@ export default function InventoryView({
}, 0);
}, 0).toLocaleString('en-IN')}
</h3>
<p className="text-[9px] text-slate-400 font-semibold mt-0.5">Units on hand</p>
<p className="text-[9px] font-semibold mt-0.5 opacity-50">Units on hand</p>
</div>
</div>
</ShellCard>
{/* Card 4: Catalog Health */}
<div className="bg-white rounded-lg p-2.5 px-3 border border-slate-200 shadow-sm hover:border-amber-200 transition-all group">
<ShellCard className="p-2.5 px-3 hover:border-amber-200 transition-all group">
<div className="flex justify-between items-start">
<span className="text-[9px] text-slate-500 uppercase tracking-widest font-bold">Catalogue Sync Ratio</span>
<span className="text-[9px] uppercase tracking-widest font-bold opacity-60">Catalogue Sync Ratio</span>
<div className="p-1 rounded-md bg-amber-50 text-amber-600 group-hover:scale-110 transition-transform">
<ShieldCheck className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-0.5">
<h3 className="text-base font-extrabold tracking-tight font-mono text-slate-900">
<h3 className="text-base font-extrabold tracking-tight font-mono">
{products.length > 0 ? `${Math.round((products.filter(p => p.verified).length / products.length) * 100)}%` : '100%'}
</h3>
<p className="text-[9px] text-slate-400 font-semibold mt-0.5">Active Portfolio</p>
<p className="text-[9px] font-semibold mt-0.5 opacity-50">Active Portfolio</p>
</div>
</div>
</ShellCard>
</div>
</div>
)}
@@ -509,47 +501,47 @@ export default function InventoryView({
<div className="flex flex-col xl:flex-row gap-6 mt-2 flex-1 min-h-0 overflow-hidden pb-4">
{isLocalSidebarOpen && (
<div className="w-full xl:w-52 shrink-0 flex flex-col gap-5 pr-2 pb-2">
<div className="bg-white border border-slate-200 shadow-sm overflow-hidden flex-1 min-h-0 flex flex-col">
<div className="p-4 bg-slate-50 border-b border-slate-100 shrink-0">
<h3 className="font-bold text-sm text-slate-800 flex items-center gap-2">
<ShellCard className="overflow-hidden flex-1 min-h-0 flex flex-col">
<div className="p-4 border-b border-slate-100 shrink-0">
<h3 className="font-bold text-sm flex items-center gap-2">
<Filter size={16} className="text-[#662582]" /> Filter Product
</h3>
</div>
<div className="p-4 space-y-5 overflow-y-auto custom-scrollbar">
<div>
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
<h3 className="text-[11px] font-extrabold uppercase tracking-widest mb-3 flex items-center gap-1.5 opacity-60">
<Layers size={14} className="text-[#662582]" /> Categories
</h3>
{/* PHASE 6 — the category filter was an `appearance-none` box
with a floating tick icon: a real checkbox stripped of its
focus ring and its label association, so it was invisible to
keyboard focus and announced nothing. CheckboxInput's label
IS the accessible name. */}
<div className="space-y-1.5">
{categories.map((cat) => (
<label key={cat} className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-50 cursor-pointer group transition-colors">
<div className="relative flex items-center justify-center">
<input
type="checkbox"
checked={selectedCategories.includes(cat)}
onChange={() => toggleCategory(cat)}
className="peer appearance-none w-5 h-5 border-2 border-slate-300 rounded-lg checked:border-[#662582] checked:bg-[#662582] transition-colors cursor-pointer"
/>
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
</div>
<span className={`text-xs font-semibold select-none transition-colors ${selectedCategories.includes(cat) ? 'text-[#662582]' : 'text-slate-600 group-hover:text-slate-900'}`}>
{cat}
</span>
</label>
<div key={cat} className="p-2 rounded-xl">
<CheckboxInput
label={cat}
value={selectedCategories.includes(cat)}
onChange={() => toggleCategory(cat)}
/>
</div>
))}
</div>
</div>
{(selectedCategories.length > 0 || localSearch) && (
<button
onClick={() => { setSelectedCategories([]); setLocalSearch(''); }}
className="w-full py-2 bg-slate-100 text-slate-600 rounded-xl text-[10px] font-bold uppercase tracking-wider hover:bg-slate-200 transition-colors cursor-pointer border-none mt-4"
>
Clear All Filters
</button>
<div className="mt-4">
<Button
label="Clear all filters"
variant="secondary"
size="sm"
onClick={() => { setSelectedCategories([]); setLocalSearch(''); }}
/>
</div>
)}
</div>
</div>
</ShellCard>
</div>
)}
@@ -572,54 +564,53 @@ export default function InventoryView({
</div>
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto shrink-0">
<button
<Button
label="Filter"
variant="secondary"
size="sm"
icon={<Filter size={12} />}
onClick={() => setIsLocalSidebarOpen(!isLocalSidebarOpen)}
className="flex items-center gap-1.5 bg-white border border-slate-200 px-2.5 py-1.5 rounded-lg text-[10px] font-bold hover:bg-slate-50 transition-colors text-slate-700 shadow-sm cursor-pointer h-[32px] whitespace-nowrap"
>
<Filter size={12} /> Filter
</button>
<div className="relative flex-1 sm:flex-none w-full sm:w-32 xl:w-48 h-[32px]">
<input
type="text"
/>
<div className="flex-1 sm:flex-none w-full sm:w-32 xl:w-48">
<TextInput
label="Search catalogue"
isLabelHidden
size="sm"
placeholder="Search..."
value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)}
className="w-full h-full pl-8 pr-3 py-1.5 bg-white border border-slate-200 rounded-lg text-[10px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-[#662582] transition-all shadow-sm font-medium"
onChange={(v) => setLocalSearch(v)}
startIcon={<Search size={14} />}
hasClear
/>
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 w-3.5 h-3.5" />
{localSearch && (
<button
onClick={() => setLocalSearch('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 bg-transparent border-none cursor-pointer"
>
<X size={10} />
</button>
)}
</div>
<button
onClick={() => setActiveTab('requests')}
className="flex items-center gap-1.5 bg-slate-50 hover:bg-slate-100 border border-slate-200 text-slate-700 px-2.5 py-1.5 rounded-lg text-[10px] font-bold transition-colors shadow-sm cursor-pointer h-[32px] whitespace-nowrap"
>
<Inbox size={12} /> Stock Requests
{storeRequests.length > 0 && (
<span className="ml-1 bg-rose-500 text-white text-[8px] px-1 py-0.5 rounded-sm leading-none">{storeRequests.length}</span>
)}
</button>
<button
{/* The request count moves out of the button's text and into a
Badge — a Button's label is its accessible name, and
"Stock Requests 3" is not one. */}
<div className="flex items-center gap-1">
<Button
label="Stock Requests"
variant="secondary"
size="sm"
icon={<Inbox size={12} />}
onClick={() => setActiveTab('requests')}
/>
{storeRequests.length > 0 && <Badge variant="error" label={String(storeRequests.length)} />}
</div>
<Button
label="Offline Sales"
variant="secondary"
size="sm"
icon={<UploadCloud size={12} />}
onClick={() => setShowOfflineSales(true)}
title="Upload an Excel sheet of counter sales to deduct stock and record the revenue"
className="flex items-center gap-1.5 bg-slate-50 hover:bg-slate-100 border border-slate-200 text-slate-700 px-2.5 py-1.5 rounded-lg text-[10px] font-bold transition-colors shadow-sm cursor-pointer h-[32px] whitespace-nowrap"
>
<UploadCloud size={12} /> Offline Sales
</button>
<button
/>
<Button
label="Import Product"
size="sm"
icon={<Plus size={12} />}
onClick={() => setActiveTab('global_catalogue')}
className="flex items-center gap-1.5 bg-[#662582] hover:bg-purple-800 text-white px-2.5 py-1.5 rounded-lg text-[10px] font-bold transition-colors shadow-sm cursor-pointer h-[32px] whitespace-nowrap"
>
<Plus size={12} /> Import Product
</button>
/>
</div>
</div>
@@ -640,7 +631,16 @@ export default function InventoryView({
))}
</div>
) : filteredProducts.length === 0 ? (
<div className="text-center py-xl text-zinc-400 text-xs font-bold">No catalogue products match your selection.</div>
<EmptyState
icon={<Package size={26} />}
title="No catalogue products match your selection"
description={localSearch || selectedCategories.length ? 'Try a different search or category.' : 'Import products to build the catalogue.'}
actions={
localSearch || selectedCategories.length
? <Button label="Clear all filters" variant="secondary" onClick={() => { setSelectedCategories([]); setLocalSearch(''); }} />
: <Button label="Import Product" icon={<Plus size={14} />} onClick={() => setActiveTab('global_catalogue')} />
}
/>
) : (
<div className="flex flex-col xl:flex-row gap-6">
{/* Left Side: Normal Catalogue */}
@@ -688,35 +688,41 @@ export default function InventoryView({
</div>
{isPublished ? (
<button
onClick={(e) => { e.stopPropagation(); storeCat.remove(prod.id); }}
title="Remove from store catalogue"
className="w-full bg-emerald-50 hover:bg-rose-50 text-emerald-700 hover:text-rose-600 border border-emerald-200 hover:border-rose-200 px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
>
<CheckCircle size={12} />
Remove from Store
</button>
<Button
label="Remove from Store"
variant="secondary"
size="sm"
icon={<CheckCircle size={12} />}
onClick={(e: React.MouseEvent) => { e.stopPropagation(); storeCat.remove(prod.id); }}
/>
) : addingPriceProdId === prod.id ? (
<div className="flex flex-col gap-1.5 p-2 bg-slate-50 border border-slate-200 rounded-none" onClick={(e) => e.stopPropagation()}>
<label className="text-[9px] font-extrabold text-slate-600 uppercase tracking-widest text-center">
Set Selling Price (₹)
</label>
<span className="text-[8px] font-bold text-slate-400 uppercase tracking-wider text-center -mt-1">
<div className="flex flex-col gap-1.5 p-2 rounded-none" style={{ background: 'rgba(127,127,127,0.06)' }} onClick={(e) => e.stopPropagation()}>
<span className="text-[8px] font-bold uppercase tracking-wider text-center opacity-60">
{allLocationIds.length === 1 ? 'Your store' : `All ${allLocationIds.length} stores`}
</span>
<input
type="number"
value={cardImportPrice}
onChange={(e) => setCardImportPrice(e.target.value)}
placeholder="e.g. 50"
className="px-2 py-1 border border-slate-200 rounded-none text-[10px] font-bold focus:outline-none focus:border-[#662582]"
autoFocus
{/* NumberInput carries its own label, so the
hand-rolled <label> above it is gone rather
than announced twice. */}
<NumberInput
label="Set Selling Price (₹)"
size="sm"
value={cardImportPrice === '' ? null : Number(cardImportPrice)}
onChange={(v) => setCardImportPrice(v == null ? '' : String(v))}
min={0}
/>
<div className="flex gap-1">
<button onClick={(e) => { e.stopPropagation(); setAddingPriceProdId(null); }} className="flex-1 py-1 bg-white text-slate-500 border border-slate-200 text-[9px] font-bold">Cancel</button>
<button
disabled={!cardImportPrice || Number(cardImportPrice) <= 0 || publishMut.isPending}
onClick={(e) => {
<Button
label="Cancel"
variant="secondary"
size="sm"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setAddingPriceProdId(null); }}
/>
<Button
label="Confirm"
size="sm"
isLoading={publishMut.isPending}
isDisabled={!cardImportPrice || Number(cardImportPrice) <= 0 || publishMut.isPending}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
// Prices AND publishes, in one server-side step.
//
@@ -748,24 +754,21 @@ export default function InventoryView({
},
);
}}
className="flex-1 py-1 bg-[#662582] text-white text-[9px] font-bold disabled:opacity-50"
>
Confirm
</button>
/>
</div>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation();
<Button
label="Add to Store"
variant="secondary"
size="sm"
icon={<Plus size={12} />}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setCardImportPrice(String(prod.price || (prod.unitsSold > 0 ? Math.round(prod.revenue / prod.unitsSold) : 0)));
setAddingPriceProdId(prod.id);
setAddingPriceProdId(prod.id);
}}
className="w-full bg-[#f8fafc] hover:bg-[#662582] text-slate-600 hover:text-white border border-slate-200 hover:border-[#662582] px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
title="Add to Store Catalogue"
>
<Plus size={12} /> Add to Store
</button>
/>
)}
</div>
</div>
@@ -783,13 +786,15 @@ export default function InventoryView({
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar animate-in fade-in duration-300">
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-end gap-4 mb-2">
<div className="flex items-center gap-4">
<button
{/* Was a hand-drawn chevron <svg> in a bare button with only a
`title` — no accessible name, and lucide's ArrowLeft was
already imported. */}
<IconButton
label="Back to Catalogue"
variant="ghost"
icon={<ArrowLeft size={20} />}
onClick={() => setActiveTab('catalog')}
className="p-2 -ml-2 hover:bg-slate-100 rounded-xl transition-colors cursor-pointer text-slate-500 hover:text-slate-900"
title="Back to Catalogue"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m15 18-6-6 6-6"/></svg>
</button>
/>
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-purple-50 to-indigo-50 border border-purple-100 flex items-center justify-center shadow-[0_2px_10px_-3px_rgba(102,37,130,0.15)] shrink-0">
<Inbox size={24} className="text-[#662582]" strokeWidth={2.5} />
</div>
@@ -806,172 +811,160 @@ export default function InventoryView({
</div>
</div>
{/* PHASE 6 — both filters were `appearance-none` selects with a
hand-drawn chevron layered on top and no label; the store one had
a decorative icon absolutely positioned over the text. Selector
supplies the chevron, the label and the keyboard behaviour. */}
<div className="flex items-center gap-3">
<div className="relative group">
<Store size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-hover:text-[#662582] transition-colors z-10" />
<select
<div className="w-48">
<Selector
label="Filter by store"
isLabelHidden
value={requestStoreFilter}
onChange={(e) => setRequestStoreFilter(e.target.value)}
className="appearance-none bg-white border border-slate-200 text-slate-700 pl-9 pr-10 py-2.5 rounded-xl text-sm font-semibold shadow-sm outline-none cursor-pointer hover:border-[#662582]/40 hover:shadow-md transition-all focus:border-[#662582] focus:ring-2 focus:ring-[#662582]/20"
>
<option value="All Stores">All Stores</option>
{requestingStores.map(store => (
<option key={store} value={store}>{store}</option>
))}
</select>
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400 group-hover:text-[#662582] transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
onChange={(v) => setRequestStoreFilter(v || 'All Stores')}
startIcon={<Store size={14} />}
options={['All Stores', ...requestingStores]}
/>
</div>
<div className="relative group">
<select
<div className="w-44">
<Selector
label="Filter by status"
isLabelHidden
value={requestStatusFilter}
onChange={(e) => setRequestStatusFilter(e.target.value)}
className="appearance-none bg-white border border-slate-200 text-slate-700 pl-4 pr-10 py-2.5 rounded-xl text-sm font-semibold shadow-sm outline-none cursor-pointer hover:border-[#662582]/40 hover:shadow-md transition-all focus:border-[#662582] focus:ring-2 focus:ring-[#662582]/20"
>
<option value="All Statuses">All Statuses</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="received">Received</option>
</select>
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400 group-hover:text-[#662582] transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
onChange={(v) => setRequestStatusFilter(v || 'All Statuses')}
options={[
{ value: 'All Statuses', label: 'All Statuses' },
{ value: 'pending', label: 'Pending' },
{ value: 'approved', label: 'Approved' },
{ value: 'rejected', label: 'Rejected' },
{ value: 'received', label: 'Received' },
]}
/>
</div>
<div className="flex items-center gap-2">
<input
type="date"
value={requestDate}
onChange={(e) => setRequestDate(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-[#662582] focus:border-[#662582] text-sm font-semibold text-slate-700 bg-white hover:border-[#662582]/40 hover:shadow-md transition-all"
<div className="w-44">
<DateInput
label="Request date"
isLabelHidden
value={requestDate as ISODateString}
onChange={(v) => { if (v) setRequestDate(v); }}
/>
</div>
</div>
</div>
{flattenedRequests.length === 0 ? (
<div className="flex flex-col items-center justify-center h-80 bg-gradient-to-b from-white to-slate-50 border border-slate-200/60 border-dashed rounded-2xl shadow-sm">
<div className="w-20 h-20 rounded-full bg-slate-100/80 flex items-center justify-center mb-6 shadow-inner">
<Inbox size={32} className="text-slate-400" />
</div>
<h3 className="text-xl font-bold text-slate-700 tracking-tight">No Pending Requests</h3>
<p className="text-sm font-medium text-slate-400 mt-2 text-center w-full max-w-[400px] leading-relaxed">
You're all caught up! All store stock requests have been processed and there is currently no pending action required.
</p>
</div>
<ShellCard className="py-20">
<EmptyState
icon={<Inbox size={32} />}
title="No Pending Requests"
description="You're all caught up! All store stock requests have been processed and there is currently no pending action required."
/>
</ShellCard>
) : (
<div className="bg-white rounded-2xl border border-slate-200 shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] overflow-hidden">
<ShellCard className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse" style={{ minWidth: 960 }}>
<thead>
<tr className="bg-slate-50/80 border-b border-slate-200">
<Table style={{ minWidth: 960 }}>
<TableHeader>
<TableRow>
{['Requested At', 'Store', 'Product', 'Qty', 'Status', 'Resolved At', ''].map((h, i) => (
<th key={i} className="px-5 py-4 text-[10px] font-black uppercase tracking-widest text-slate-500 whitespace-nowrap">{h}</th>
<TableHeaderCell key={i}>{h}</TableHeaderCell>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
</TableRow>
</TableHeader>
<TableBody>
{flattenedRequests.map((req, idx) => {
const isApproved = req.pickData.status === 'Approved';
const isRejected = req.pickData.status === 'Rejected';
const isPending = req.pickData.status === 'Pending';
const isCancelled = req.pickData.status === 'Cancelled';
const isReceived = req.pickData.status === 'Received';
// The five-way class ladder collapses to a Badge variant.
// 'Received' was indigo and 'Cancelled' slate; neither has a
// Badge equivalent, so they map to the nearest semantic
// meaning (info / neutral) rather than an invented hue.
const statusVariant =
req.pickData.status === 'Received' ? 'info' :
req.pickData.status === 'Approved' ? 'success' :
req.pickData.status === 'Rejected' ? 'error' :
req.pickData.status === 'Cancelled' ? 'neutral' :
'warning';
return (
<tr
key={`${req.locationid}-${req.productid}-${idx}`}
className="transition-all duration-200 hover:bg-slate-50/80 group cursor-pointer"
<TableRow
key={`${req.locationid}-${req.requestid}`}
className="group cursor-pointer"
onClick={() => setSelectedRequest(req)}
>
<td className="px-5 py-4 whitespace-nowrap">
<div className="flex flex-col">
<span className="text-sm font-semibold text-slate-900">
<TableCell>
<div className="flex flex-col whitespace-nowrap">
<span className="text-sm font-semibold">
{req.pickData.requestedAt ? new Date(req.pickData.requestedAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'}
</span>
<span className="text-xs font-medium text-slate-500">
<span className="text-xs font-medium opacity-60">
{req.pickData.requestedAt ? new Date(req.pickData.requestedAt).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
</td>
<td className="px-5 py-4 whitespace-nowrap">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-indigo-50/50 text-indigo-700 border border-indigo-100/50 transition-colors group-hover:bg-indigo-100/50">
<Store size={14} className="text-indigo-500" />
</TableCell>
<TableCell>
<span className="inline-flex items-center gap-2 whitespace-nowrap">
<Store size={14} className="text-indigo-500" />
<span className="font-bold text-xs">{req.locationname}</span>
</div>
</td>
<td className="px-5 py-4">
</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="relative w-10 h-10 rounded-xl overflow-hidden border border-slate-200 bg-white shadow-sm shrink-0 group-hover:shadow transition-shadow">
<img src={req.product?.image} alt={req.product?.name} className="w-full h-full object-cover" />
<div className="relative w-10 h-10 rounded-xl overflow-hidden shrink-0">
<img src={req.product?.image} alt="" className="w-full h-full object-cover" />
</div>
<div className="min-w-0 flex-1">
<p className="font-bold text-sm text-slate-900 truncate group-hover:text-indigo-700 transition-colors">{req.product?.name}</p>
<p className="text-xs font-medium text-slate-500 truncate mt-0.5">{req.product?.sku}</p>
<p className="font-bold text-sm truncate">{req.product?.name}</p>
<p className="text-xs font-medium truncate mt-0.5 opacity-60">{req.product?.sku}</p>
</div>
</div>
</td>
<td className="px-5 py-4 whitespace-nowrap">
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 rounded-md bg-slate-100 text-slate-700 font-bold text-sm border border-slate-200/60">
{req.pickData.qty || '—'}
</span>
</td>
<td className="px-5 py-4 whitespace-nowrap">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${
isReceived ? 'bg-indigo-50 text-indigo-700 border-indigo-200' :
isApproved ? 'bg-emerald-50 text-emerald-700 border-emerald-200' :
isRejected ? 'bg-rose-50 text-rose-700 border-rose-200' :
isCancelled ? 'bg-slate-50 text-slate-700 border-slate-200' :
'bg-amber-50 text-amber-700 border-amber-200'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${
isReceived ? 'bg-indigo-500' :
isApproved ? 'bg-emerald-500' :
isRejected ? 'bg-rose-500' :
isCancelled ? 'bg-slate-400' :
'bg-amber-500 animate-pulse'
}`} />
{req.pickData.status || '—'}
</span>
</td>
<td className="px-5 py-4 whitespace-nowrap">
<span className="text-xs font-medium text-slate-500">
</TableCell>
<TableCell>
<span className="font-bold text-sm tabular-nums">{req.pickData.qty || '—'}</span>
</TableCell>
<TableCell>
<Badge variant={statusVariant} label={req.pickData.status || '—'} />
</TableCell>
<TableCell>
<span className="text-xs font-medium whitespace-nowrap opacity-60">
{req.pickData.resolvedAt ? new Date(req.pickData.resolvedAt).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'}
</span>
</td>
<td className="px-5 py-4 whitespace-nowrap text-right">
</TableCell>
<TableCell>
{isPending ? (
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Approved'); }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold text-emerald-700 bg-emerald-50 hover:bg-emerald-100 hover:text-emerald-800 border border-emerald-200 transition-all active:scale-95"
title="Approve Request"
>
<CheckCircle size={14} /> Approve
</button>
<button
onClick={(e) => { e.stopPropagation(); updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Rejected'); }}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold text-rose-700 bg-rose-50 hover:bg-rose-100 hover:text-rose-800 border border-rose-200 transition-all active:scale-95"
title="Reject Request"
>
<X size={14} /> Reject
</button>
// The `opacity-0 group-hover:opacity-100` reveal is
// gone: it hid the only two actions on the row from
// anyone not using a mouse, and a keyboard user
// could tab to an invisible button.
<div className="flex items-center justify-end gap-2">
<Button
label="Approve"
variant="secondary"
size="sm"
icon={<CheckCircle size={14} />}
onClick={(e: React.MouseEvent) => { e.stopPropagation(); updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Approved'); }}
/>
<Button
label="Reject"
variant="destructive"
size="sm"
icon={<X size={14} />}
onClick={(e: React.MouseEvent) => { e.stopPropagation(); updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Rejected'); }}
/>
</div>
) : (
<span className="text-[10px] font-bold text-slate-300 uppercase tracking-widest">
<span className="text-[10px] font-bold uppercase tracking-widest text-right block opacity-40">
Processed
</span>
)}
</td>
</tr>
</TableCell>
</TableRow>
);
})}
</tbody>
</table>
</TableBody>
</Table>
</div>
</div>
</ShellCard>
)}
</div>
) : activeTab === 'global_catalogue' ? (
@@ -1070,23 +1063,29 @@ export default function InventoryView({
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5" onClick={(e) => e.stopPropagation()}>
<label className="text-[10px] font-extrabold text-slate-500 uppercase tracking-wider">Selling Price (₹)</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 font-bold pointer-events-none">₹</span>
<input
type="number"
step="0.01"
value={importPrice}
onChange={(e) => setImportPrice(e.target.value)}
onClick={(e) => e.stopPropagation()}
placeholder="e.g. 50"
className="w-full pl-7 pr-3 py-2.5 border border-slate-200 rounded-xl text-sm font-bold text-slate-900 focus:outline-none focus:ring-2 focus:ring-[#662582]/20 focus:border-[#662582] bg-white transition-all shadow-xs"
/>
</div>
{/* The ₹ was an absolutely-positioned span overlapping the
field's padding; NumberInput carries it as a real prefix
so it cannot drift out of alignment. */}
<NumberInput
label="Selling Price (₹)"
value={importPrice === '' ? null : Number(importPrice)}
onChange={(v) => setImportPrice(v == null ? '' : String(v))}
step={0.01}
min={0}
/>
</div>
<button
disabled={!importPrice || Number(importPrice) <= 0 || isPricing}
<Button
label={
isPricing
? 'Saving…'
: storeCat.has(selectedAdminProduct.id)
? 'Save Price to All Stores'
: 'Save Price & Publish to All Stores'
}
icon={<CheckCircle size={16} strokeWidth={2.5} />}
isLoading={isPricing}
isDisabled={!importPrice || Number(importPrice) <= 0 || isPricing}
onClick={() => {
// Publishing is a listing/pricing action, never a stock delivery — real
// quantity only lands via Mark as Received. priceEverywhere() sends
@@ -1105,26 +1104,18 @@ export default function InventoryView({
},
);
}}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl text-xs font-extrabold transition-all bg-[#662582] hover:bg-[#531e6a] text-white shadow-md disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<CheckCircle size={16} strokeWidth={2.5} />
{isPricing
? 'Saving…'
: storeCat.has(selectedAdminProduct.id)
? 'Save Price to All Stores'
: 'Save Price & Publish to All Stores'}
</button>
<p className="text-[10px] font-semibold text-slate-500 text-center -mt-1">
/>
<p className="text-[10px] font-semibold text-center -mt-1 opacity-60">
Applies to {allLocationIds.length === 1 ? 'your store' : `all ${allLocationIds.length} stores`}
</p>
{storeCat.has(selectedAdminProduct.id) && (
<button
onClick={() => storeCat.remove(selectedAdminProduct.id)}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-white text-rose-600 border border-slate-200 font-bold text-xs transition-colors hover:bg-slate-50 cursor-pointer mt-1"
>
<X size={14} strokeWidth={2} /> Remove from Store Catalogue
</button>
<Button
label="Remove from Store Catalogue"
variant="destructive"
icon={<X size={14} strokeWidth={2} />}
onClick={() => storeCat.remove(selectedAdminProduct.id)}
/>
)}
</div>
</div>
@@ -1201,24 +1192,21 @@ export default function InventoryView({
{isPending ? (
<div className="flex gap-3">
<button
<Button
label="Approve Stock"
icon={<CheckCircle size={18} />}
onClick={() => { updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Approved'); setSelectedRequest(null); }}
className="flex-1 py-3 rounded-xl font-extrabold cursor-pointer transition-colors text-white flex items-center justify-center gap-2 shadow-sm"
style={{ background: '#10b981', border: '1px solid #059669' }}
>
<CheckCircle size={18} /> Approve Stock
</button>
<button
/>
<Button
label="Reject"
variant="destructive"
icon={<X size={18} />}
onClick={() => { updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Rejected'); setSelectedRequest(null); }}
className="flex-1 py-3 rounded-xl font-extrabold cursor-pointer transition-colors text-rose-600 flex items-center justify-center gap-2 shadow-sm"
style={{ background: tint('#f43f5e'), border: `1px solid ${edge('#f43f5e')}` }}
>
<X size={18} /> Reject
</button>
/>
</div>
) : (
<div className="flex items-center justify-center gap-2 p-3 bg-slate-50 rounded-xl border border-slate-200 shadow-sm text-center">
<span className="text-sm font-bold text-slate-500">
<div className="flex items-center justify-center gap-2 p-3 rounded-xl text-center" style={{ background: 'rgba(127,127,127,0.06)' }}>
<span className="text-sm font-bold opacity-70">
This request has been {req.pickData.status.toLowerCase()}.
</span>
</div>

View File

@@ -1,789 +0,0 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import {
Layers,
AlertTriangle,
CheckCircle,
HelpCircle,
ArrowUpRight,
TrendingUp,
Sliders,
DollarSign,
PackageCheck,
PlusSquare,
ArrowRightLeft,
XCircle,
FolderSync,
UploadCloud,
Download,
X,
} from 'lucide-react';
import { InventoryItem } from '../types';
import {
useFiestaStockStatement,
useFiestaTenantLocations,
} from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr } from '../services/fiestaApi';
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi';
import { useNotify } from './Toasts';
interface OperationsViewProps {
searchQuery: string;
isCoimbatoreView: boolean;
}
export default function OperationsView({ searchQuery, isCoimbatoreView }: OperationsViewProps) {
// PHASE 6 — replaces alert(). A native alert freezes the whole browser
// until dismissed; a toast does not, and errors stay until read.
const { notifyOk, notifyFail } = useNotify();
// Sub-tabs state
const [activeSubTab, setActiveSubTab] = useState<'inventory' | 'catalogue' | 'import'>('inventory');
// ── Live operations data (Fiesta) ─────────────────────────────────────────
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
const primaryLocation =
(locationsQ.data ?? []).find((l) => Number(l.locationid) === FIESTA_PRIMARY_LOCATION_ID) ||
(locationsQ.data ?? [])[0];
const locationId = primaryLocation ? Number(primaryLocation.locationid) : FIESTA_PRIMARY_LOCATION_ID;
const locationName = fstr(primaryLocation?.locationname) || 'Primary Outlet';
const stockQ = useFiestaStockStatement({
tenantid: FIESTA_TENANT_ID,
locationid: locationId,
keyword: '',
pageno: 1,
pagesize: 100,
});
// Total inventory value = Σ closing × unit cost across the live stock statement.
const inventoryValue = (stockQ.data ?? []).reduce(
(sum, r) => sum + fnum(r.closing) * fnum(r.productcost),
0,
);
// Dynamic state arrays for interaction (seeded from live data once it loads).
const [inventoryList, setInventoryList] = useState<InventoryItem[]>([]);
const [productList, setProductList] = useState<ReturnType<typeof stockRowToProduct>[]>([]);
useEffect(() => {
if (stockQ.data) {
setProductList(stockQ.data.map(stockRowToProduct));
setInventoryList(stockQ.data.map((r) => stockRowToInventory(r, locationName)));
}
}, [stockQ.data, locationName]);
// Modal open states
const [showAddSkuModal, setShowAddSkuModal] = useState(false);
const [showTransferModal, setShowTransferModal] = useState(false);
// Form states
const [newSku, setNewSku] = useState({
sku: '',
name: '',
warehouse: '',
stockLevel: 0,
maxCapacity: 1000,
status: 'Optimal' as 'Optimal' | 'Low Stock' | 'Critical',
region: 'CBE-NORTH' as 'CBE-NORTH' | 'CBE-SOUTH' | 'CBE-EAST' | 'CBE-WEST' | 'TIRUPPUR'
});
const [transferData, setTransferData] = useState({
sku: '',
origin: '',
destination: '',
quantity: 100
});
// Filter lists based on global Search query
const filteredInventory = inventoryList.filter(item =>
item.sku.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.warehouse.toLowerCase().includes(searchQuery.toLowerCase())
);
const filteredCatalogue = productList.filter(prod =>
prod.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
prod.sku.toLowerCase().includes(searchQuery.toLowerCase()) ||
prod.category.toLowerCase().includes(searchQuery.toLowerCase())
);
// Form submit handles
const handleAddSku = (e: React.FormEvent) => {
e.preventDefault();
if (!newSku.sku || !newSku.name || !newSku.warehouse) {
notifyFail('Fill out all metadata fields before committing.');
return;
}
const itemToAdd: InventoryItem = {
sku: newSku.sku,
name: newSku.name,
warehouse: newSku.warehouse,
stockLevel: Number(newSku.stockLevel),
maxCapacity: Number(newSku.maxCapacity),
status: newSku.stockLevel < 20 ? 'Critical' : newSku.stockLevel < 100 ? 'Low Stock' : 'Optimal',
region: newSku.region
};
setInventoryList([itemToAdd, ...inventoryList]);
setShowAddSkuModal(false);
// reset form
setNewSku({
sku: '',
name: '',
warehouse: '',
stockLevel: 0,
maxCapacity: 1000,
status: 'Optimal',
region: 'CBE-NORTH'
});
};
const handleExecuteTransfer = (e: React.FormEvent) => {
e.preventDefault();
if (!transferData.sku || !transferData.origin || !transferData.destination) {
notifyFail('Configure the transfer coordinates and SKU first.');
return;
}
// Attempt to update origin stock levels
setInventoryList(prev =>
prev.map(item => {
if (item.sku === transferData.sku) {
const newLevel = Math.max(0, item.stockLevel - transferData.quantity);
return {
...item,
stockLevel: newLevel,
status: newLevel < 20 ? 'Critical' : newLevel < 100 ? 'Low Stock' : 'Optimal'
};
}
return item;
})
);
notifyOk(`Transfer of ${transferData.quantity} units committed successfully. Tracking ID: TRF-${Math.floor(Math.random() * 900000 + 100000)}`);
setShowTransferModal(false);
};
const handleToggleProductExposure = (id: string) => {
setProductList(prev =>
prev.map(p => p.id === id ? { ...p, verified: !p.verified } : p)
);
};
return (
<div className="space-y-lg animate-in fade-in duration-500">
{/* Tab Navigation header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end border-b border-[#e2e8f0] pb-px gap-2">
<div>
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
Operations Hub{isCoimbatoreView && ': Coimbatore'}
</h1>
<p className="text-zinc-500 font-sans text-xs mt-1">
Global product assortment, inventory levels, warehouse tracking, and data sync tools.
</p>
</div>
{/* Dynamic Nav Sub-Tabs */}
<nav className="flex gap-lg">
{(['inventory', 'catalogue', 'import'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveSubTab(tab)}
className={`font-sans text-xs font-semibold uppercase tracking-wider pb-2 cursor-pointer transition-colors relative ${
activeSubTab === tab
? 'text-[#662582] font-bold'
: 'text-zinc-400 hover:text-zinc-600'
}`}
>
{tab}
{activeSubTab === tab && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#662582] animate-in slide-in-from-left-2 duration-100" />
)}
</button>
))}
</nav>
</div>
{/* Dynamic Display Area based on tabs selection */}
{activeSubTab === 'inventory' && (
<div className="space-y-lg">
{/* Top Key Operational Indicators Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-gutter">
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex justify-between items-start shadow-sm">
<div>
<p className="text-[11px] font-sans font-bold text-zinc-500 uppercase tracking-widest">
Total Inventory Value
</p>
<h3 className="font-sans font-bold text-[#0f172a] text-xl mt-xs">
₹{inventoryValue.toLocaleString('en-IN', { maximumFractionDigits: 0 })}
</h3>
<p className="text-[11px] text-emerald-600 font-semibold mt-sm flex items-center gap-1.5">
<TrendingUp size={12} />
{productList.length} SKUs · {locationName}
</p>
</div>
<div className="p-2.5 rounded-lg bg-purple-50 text-[#662582]">
<DollarSign size={18} />
</div>
</div>
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex justify-between items-start shadow-sm">
<div>
<p className="text-[11px] font-sans font-bold text-zinc-500 uppercase tracking-widest">
Low Stock Alerts
</p>
<h3 className="font-sans font-bold text-rose-500 text-xl mt-xs">
{inventoryList.filter(item => item.status !== 'Optimal').length} SKUs
</h3>
<p className="text-[11px] text-zinc-400 mt-sm">Across local regional warehouses</p>
</div>
<div className="p-2.5 rounded-lg bg-rose-50 text-rose-500">
<AlertTriangle size={18} />
</div>
</div>
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex justify-between items-start shadow-sm">
<div>
<p className="text-[11px] font-sans font-bold text-zinc-500 uppercase tracking-widest">
Fulfillment Health
</p>
<AwaitingApi label="Fulfillment health" api="[R1]" compact className="mt-xs" />
</div>
<div className="p-2.5 rounded-lg bg-emerald-50 text-emerald-600 animate-pulse">
<PackageCheck size={18} />
</div>
</div>
</div>
{/* Sub Grid splits: Product state table (3 cols) and Action sidebar panels (1 col) */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-gutter">
{/* Left table container */}
<div className="lg:col-span-3 bg-white border border-[#e2e8f0] rounded-xl overflow-hidden flex flex-col justify-between shadow-sm">
<div>
<div className="p-md border-b border-[#e2e8f0] flex justify-between items-center bg-[#f8fafc]">
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
Product Inventory Levels
</h4>
<div className="flex gap-2 text-xs">
<span className="text-zinc-500 font-medium">Auto-synced</span>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left font-sans text-xs">
<thead className="bg-[#f8fafc] border-b border-[#e2e8f0] text-zinc-500 text-[10px] uppercase tracking-wider font-semibold">
<tr>
<th className="p-md">Product SKU</th>
<th className="p-md">Warehouse</th>
<th className="p-md">Stock Level</th>
<th className="p-md">Status</th>
<th className="p-md text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[#f1f5f9]">
{filteredInventory.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-10 text-zinc-400">
No matching items identified. Try another query or reload.
</td>
</tr>
) : (
filteredInventory.map((item, idx) => {
const percentage = (item.stockLevel / item.maxCapacity) * 100;
return (
<tr key={idx} className="hover:bg-[#f2f4f6]/50 transition-colors">
<td className="p-md">
<p className="font-bold text-[#0f172a]">{item.sku}</p>
<p className="text-[10px] text-zinc-400 font-medium">{item.name}</p>
</td>
<td className="p-md text-zinc-600 font-medium">
<span className="bg-[#f2f4f6] px-1.5 py-0.5 rounded text-[10px] font-mono mr-1">
{item.region}
</span>
{item.warehouse}
</td>
<td className="p-md">
<p className="font-mono font-bold text-zinc-700">{item.stockLevel.toLocaleString()} units</p>
<div className="w-24 bg-[#eceef0] h-1 mt-1 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
item.status === 'Critical' ? 'bg-rose-500' : item.status === 'Low Stock' ? 'bg-amber-500' : 'bg-[#662582]'
}`}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
</td>
<td className="p-md">
<span className={`px-2 py-0.5 rounded-full text-[10px] uppercase font-bold tracking-tight inline-block ${
item.status === 'Critical'
? 'bg-rose-100 text-rose-750'
: item.status === 'Low Stock'
? 'bg-amber-100 text-amber-750'
: 'bg-emerald-100 text-emerald-750'
}`}>
{item.status}
</span>
</td>
<td className="p-md text-right">
<div className="flex justify-end gap-1">
<button
onClick={() => {
setTransferData({
sku: item.sku,
origin: item.warehouse,
destination: '',
quantity: 50
});
setShowTransferModal(true);
}}
className="text-xs font-semibold text-[#662582] hover:underline cursor-pointer"
>
Transfer
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
<div className="p-md border-t border-[#f1f5f9] bg-[#f8fafc] flex justify-between items-center text-[10px] text-zinc-500 font-medium font-sans">
<span>Displaying 1-{filteredInventory.length} of {inventoryList.length} global items</span>
<span>Active Ledger Nodes Online</span>
</div>
</div>
{/* Right sidebar quick tasks */}
<div className="space-y-gutter">
{/* Forecast module visual details */}
<div className="bg-[#0f172a] text-white p-6 rounded-xl relative overflow-hidden flex flex-col justify-between shadow-md h-48">
<div>
<span className="text-[10px] tracking-wider font-bold opacity-60 uppercase">
Forecast Efficiency
</span>
<AwaitingApi
label="Forecast insights"
api="[R7]"
compact
className="mt-sm bg-white/5 border-white/15 text-zinc-300"
/>
</div>
{/* Embedded SVG graphic visual */}
<div className="absolute right-3 bottom-3 opacity-15">
<PackageCheck size={64} className="text-purple-300" />
</div>
</div>
{/* Quick Actions buttons block */}
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm">
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs mb-md border-b border-[#f1f5f9]">
Quick Actions Ledger
</span>
<div className="grid grid-cols-2 gap-sm">
<button
onClick={() => setShowAddSkuModal(true)}
className="bg-[#f8fafc] border border-[#e2e8f0] p-sm rounded-lg flex flex-col items-center gap-xs text-zinc-700 hover:bg-[#faf5ff] hover:text-[#662582] hover:border-[#662582] transition-all cursor-pointer group"
>
<PlusSquare size={16} className="text-zinc-400 group-hover:text-[#662582]" />
<span className="text-[10px] font-sans font-bold uppercase">Add SKU</span>
</button>
<button
onClick={() => {
setTransferData({ sku: 'PRO-9920-X1', origin: 'RS Puram Hub (CBE-01)', destination: '', quantity: 100 });
setShowTransferModal(true);
}}
className="bg-[#f8fafc] border border-[#e2e8f0] p-sm rounded-lg flex flex-col items-center gap-xs text-zinc-700 hover:bg-[#faf5ff] hover:text-[#662582] hover:border-[#662582] transition-all cursor-pointer group"
>
<ArrowRightLeft size={16} className="text-zinc-400 group-hover:text-[#662582]" />
<span className="text-[10px] font-sans font-bold uppercase">Transfer</span>
</button>
<button
onClick={() => {
const amount = prompt('Enter return item SKU code:');
if (amount) {
notifyOk(`Returns logged successfully for target SKU code ${amount}. Waiting for physical hub clearance inspection.`);
}
}}
className="bg-[#f8fafc] border border-[#e2e8f0] p-sm rounded-lg flex flex-col items-center gap-xs text-zinc-700 hover:bg-[#faf5ff] hover:text-[#662582] hover:border-[#662582] transition-all cursor-pointer group"
>
<XCircle size={16} className="text-zinc-400 group-hover:text-[#662582]" />
<span className="text-[10px] font-sans font-bold uppercase">Returns</span>
</button>
<button
onClick={() => {
notifyOk('Generating the audit compliance report. The download will start shortly.');
}}
className="bg-[#f8fafc] border border-[#e2e8f0] p-sm rounded-lg flex flex-col items-center gap-xs text-zinc-700 hover:bg-[#faf5ff] hover:text-[#662582] hover:border-[#662582] transition-all cursor-pointer group"
>
<FolderSync size={16} className="text-zinc-400 group-hover:text-[#662582]" />
<span className="text-[10px] font-sans font-bold uppercase">Audit CSV</span>
</button>
</div>
</div>
</div>
</div>
</div>
)}
{activeSubTab === 'catalogue' && (
<div className="space-y-md animate-in slide-in-from-right-5">
<div className="flex justify-between items-end bg-[#f8fafc] border border-[#e2e8f0] p-md rounded-xl shadow-sm">
<div>
<h3 className="font-sans font-bold text-sm text-[#0f172a]">
Master Assortment Catalogue
</h3>
<p className="text-zinc-500 text-xs font-sans mt-0.5">
Global inventory master list and exposure levels across {productList.length.toLocaleString('en-IN')} nodes.
</p>
</div>
<button
onClick={() => {
const title = prompt('Enter product brand title:');
const sku = prompt('Enter SKU catalogue code:');
const category = prompt('Enter SKU Category:');
if (title && sku && category) {
setProductList(prev => [
...prev,
{
id: String(prev.length + 1),
name: title,
sku: sku,
unitsSold: 0,
revenue: 0,
stockStatus: 'Healthy',
trend: 'flat',
image: 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=150&q=80',
category: category,
exposure: '0/120 Stores',
verified: false
}
]);
notifyOk(`${title} added successfully to unreleased draft portfolio.`);
}
}}
className="bg-[#662582] hover:bg-purple-800 active:bg-purple-900 text-white font-sans text-xs font-semibold px-4 py-2 rounded-lg cursor-pointer transition-colors shadow-sm"
>
Add Brand Product
</button>
</div>
<div className="bg-white border border-[#e2e8f0] rounded-xl overflow-hidden shadow-sm">
<table className="w-full text-left font-sans text-xs">
<thead className="bg-[#f8fafc] border-b border-[#e2e8f0] text-zinc-500 text-[10px] uppercase font-bold tracking-wider">
<tr>
<th className="p-md">Product Details</th>
<th className="p-md">Category segment</th>
<th className="p-md">Verification status</th>
<th className="p-md">Store exposure</th>
<th className="p-md text-right">Exposure toggle</th>
</tr>
</thead>
<tbody className="divide-y divide-[#f1f5f9]">
{filteredCatalogue.map((prod) => (
<tr key={prod.id} className="hover:bg-[#f2f4f6]/50 transition-colors">
<td className="p-md flex items-center gap-md">
<div className="w-10 h-10 rounded-lg border border-[#e2e8f0] overflow-hidden shrink-0 bg-zinc-50">
<img
src={prod.image}
alt={prod.name}
referrerPolicy="no-referrer"
className="w-full h-full object-cover"
/>
</div>
<div>
<p className="font-bold text-[#0f172a]">{prod.name}</p>
<p className="text-[10px] text-zinc-400 font-mono">SKU: {prod.sku}</p>
</div>
</td>
<td className="p-md text-zinc-600 font-medium">{prod.category}</td>
<td className="p-md">
{prod.verified ? (
<span className="flex items-center gap-1.5 text-emerald-600 font-semibold tracking-tight text-[11px]">
<span className="w-2 h-2 rounded-full bg-emerald-500"></span> Verified Portfolio
</span>
) : (
<span className="flex items-center gap-1.5 text-zinc-400 font-medium tracking-tight text-[11px]">
<span className="w-2 h-2 rounded-full bg-zinc-300"></span> Under Inspection
</span>
)}
</td>
<td className="p-md text-zinc-500 font-medium">{prod.exposure}</td>
<td className="p-md text-right">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={prod.verified}
onChange={() => handleToggleProductExposure(prod.id)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-zinc-200 rounded-full peer peer-focus:ring-0 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-zinc-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-[#662582]"></div>
</label>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{activeSubTab === 'import' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-gutter animate-in slide-in-from-right-5">
{/* Upload panel zone */}
<div
onClick={() => {
const fileRef = prompt('Enter CSV filename representation path:');
if (fileRef) {
notifyOk('Uploaded. Metadata schema verified.');
}
}}
className="bg-white border-2 border-dashed border-zinc-300 rounded-xl p-xl flex flex-col items-center justify-center text-center cursor-pointer hover:bg-[#faf5ff]/30 hover:border-[#662582] transition-all duration-200"
>
<div className="h-14 w-14 bg-purple-50 text-[#662582] rounded-full flex items-center justify-center mb-md shadow-sm">
<UploadCloud size={24} />
</div>
<h4 className="font-sans font-bold text-base text-[#0f172a]">
Upload Inventory CSV
</h4>
<p className="text-zinc-500 text-xs mt-2 max-w-[20rem] leading-relaxed">
Drag and drop your .csv, .xlsx or .xml sheets here to automatically update global stock balances or master portfolios schemas.
</p>
<div className="mt-lg flex gap-md">
<button className="bg-[#0f172a] text-white text-xs font-semibold px-4 py-2 rounded-lg cursor-pointer hover:bg-zinc-800 transition-colors shadow-sm">
Browse Files
</button>
<button
onClick={(e) => {
e.stopPropagation();
notifyOk('Template documentation initiated.');
}}
className="bg-white border border-[#e2e8f0] text-zinc-600 text-xs px-4 py-2 rounded-lg cursor-pointer hover:bg-zinc-50 transition-colors"
>
Template CSV
</button>
</div>
</div>
{/* Validation indicators checker logs split */}
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl flex flex-col justify-between shadow-sm">
<div>
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs mb-md border-b border-[#f1f5f9]">
Interactive Schema Validator
</span>
<AwaitingApi label="Import audit & validation" api="[R4]" />
</div>
</div>
</div>
)}
{/* MODAL 1: ADD SKU MODAL */}
{showAddSkuModal && (
<div className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-xs z-[150] flex items-center justify-center p-md animate-in fade-in duration-150">
<div className="bg-white border border-[#e2e8f0] rounded-xl w-full max-w-[28rem] shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center">
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
Add Item SKU to Balance Ledger
</h4>
<button
onClick={() => setShowAddSkuModal(false)}
className="p-1.5 hover:bg-zinc-200 rounded-full text-zinc-400 cursor-pointer text-left"
>
<X size={16} />
</button>
</div>
<form onSubmit={handleAddSku} className="p-md space-y-md text-xs">
<div className="grid grid-cols-2 gap-sm">
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">SKU Code (*)</label>
<input
type="text"
placeholder="e.g., SKU-1290-A"
value={newSku.sku}
onChange={(e) => setNewSku({ ...newSku, sku: e.target.value })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]"
required
/>
</div>
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">Product Name</label>
<input
type="text"
placeholder="e.g., Thermal Printer"
value={newSku.name}
onChange={(e) => setNewSku({ ...newSku, name: e.target.value })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]"
required
/>
</div>
</div>
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">Target Warehouse</label>
<input
type="text"
placeholder="e.g., Coimbatore main logistics CBE"
value={newSku.warehouse}
onChange={(e) => setNewSku({ ...newSku, warehouse: e.target.value })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]"
required
/>
</div>
<div className="grid grid-cols-2 gap-sm">
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">Initial Balance</label>
<input
type="number"
value={newSku.stockLevel}
onChange={(e) => setNewSku({ ...newSku, stockLevel: Number(e.target.value) })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]"
required
/>
</div>
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">Warehouse Region</label>
<select
value={newSku.region}
onChange={(e) => setNewSku({ ...newSku, region: e.target.value as any })}
className="w-full border border-[#e2e8f0] rounded-lg p-2 bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]"
>
<option value="CBE-NORTH">Coimbatore North (CBE-NORTH)</option>
<option value="CBE-SOUTH">Coimbatore South (CBE-SOUTH)</option>
<option value="CBE-EAST">Coimbatore East (CBE-EAST)</option>
<option value="CBE-WEST">Coimbatore West (CBE-WEST)</option>
<option value="TIRUPPUR">Tiruppur Regional Hub (TIRUPPUR)</option>
</select>
</div>
</div>
<div className="pt-sm border-t border-[#f1f5f9] flex justify-end gap-sm">
<button
type="button"
onClick={() => setShowAddSkuModal(false)}
className="px-4 py-2 border border-[#e2e8f0] rounded-lg font-semibold text-zinc-500 hover:bg-zinc-50 cursor-pointer"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-[#662582] text-white rounded-lg font-semibold hover:bg-purple-800 cursor-pointer shadow-sm"
>
Commit Ledger SKU
</button>
</div>
</form>
</div>
</div>
)}
{/* MODAL 2: TRANSFER STOCK MODAL */}
{showTransferModal && (
<div className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-xs z-[150] flex items-center justify-center p-md animate-in fade-in duration-150">
<div className="bg-white border border-[#e2e8f0] rounded-xl w-full max-w-[24rem] shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center">
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
Request Node Stock Transfer
</h4>
<button
onClick={() => setShowTransferModal(false)}
className="p-1 hover:bg-zinc-200 rounded-full text-zinc-400 cursor-pointer"
>
<X size={16} />
</button>
</div>
<form onSubmit={handleExecuteTransfer} className="p-md space-y-md text-xs">
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">TRANSFERRING SKU</label>
<input
type="text"
value={transferData.sku}
onChange={(e) => setTransferData({ ...transferData, sku: e.target.value })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] font-mono focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]"
required
/>
</div>
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">ORIGIN WAREHOUSE</label>
<input
type="text"
value={transferData.origin}
onChange={(e) => setTransferData({ ...transferData, origin: e.target.value })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none text-zinc-800 font-medium focus:ring-1 focus:ring-[#662582]"
required
/>
</div>
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">TARGET DESTINATION WAREHOUSE</label>
<input
type="text"
placeholder="e.g., Coimbatore South CBE-03"
value={transferData.destination}
onChange={(e) => setTransferData({ ...transferData, destination: e.target.value })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]"
required
/>
</div>
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest block text-[9px]">TRANSFER QUANTITY</label>
<input
type="number"
value={transferData.quantity}
onChange={(e) => setTransferData({ ...transferData, quantity: Number(e.target.value) })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f1f5f9] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]"
required
/>
</div>
<div className="pt-sm border-t border-[#f1f5f9] flex justify-end gap-sm">
<button
type="button"
onClick={() => setShowTransferModal(false)}
className="px-4 py-2 border border-[#e2e8f0] rounded-lg font-semibold text-zinc-500 hover:bg-zinc-50 cursor-pointer"
>
Close
</button>
<button
type="submit"
className="px-4 py-2 bg-[#662582] text-white rounded-lg font-semibold hover:bg-purple-800 cursor-pointer shadow-sm"
>
Approve Routing
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -13,10 +13,9 @@
*/
import React, { useMemo, useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import {
ShoppingBag, Clock, CheckCircle2, XCircle, MapPin, Phone,
Package, Loader2, X, Download, ChevronLeft, ChevronRight, Truck,
Package, Loader2, X, Download, ChevronLeft, ChevronRight,
UserCheck,
} from 'lucide-react';
import {
@@ -28,10 +27,21 @@ import {
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { shortTime } from '../services/fiestaMappers';
import {
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, SearchPill, FilterBar, TH_STYLE,
ORDER_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT,
tint, soft, edge, ring,
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, SearchPill, FilterBar, Card as ShellCard,
ORDER_STATUS, statusColor, BRAND, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT,
tint, soft, edge,
} from './consoleUi';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Dialog } from '@astryxdesign/core/Dialog';
import { DateInput } from '@astryxdesign/core/DateInput';
import type { ISODateString } from '@astryxdesign/core/utils';
import { Heading } from '@astryxdesign/core/Heading';
import { Spinner } from '@astryxdesign/core/Spinner';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table';
interface OrdersDeliveriesViewProps {
searchQuery?: string;
@@ -187,10 +197,28 @@ export default function OrdersDeliveriesView({
}
right={
<div className="flex items-center gap-3">
{/* PHASE 6 — two unlabelled `<input type="date">`. A screen reader
announced only "date" for both, so which end of the range you
were on was invisible. The min/max cross-guard is preserved. */}
<div className="flex items-center gap-1.5">
<input type="date" value={fromdate} max={todate} onChange={(e) => setFromdate(e.target.value)} className="rounded-full outline-none font-semibold text-xs transition-colors" style={{ padding: '6px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }} />
<DateInput
label="From date"
isLabelHidden
size="sm"
value={fromdate as ISODateString}
max={todate as ISODateString}
onChange={(v) => { if (v) setFromdate(v); }}
/>
<span className="text-zinc-400 font-bold px-1 text-xs">—</span>
<input type="date" value={todate} min={fromdate} max={todayStr} onChange={(e) => setTodate(e.target.value)} className="rounded-full outline-none font-semibold text-xs transition-colors" style={{ padding: '6px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }} />
<DateInput
label="To date"
isLabelHidden
size="sm"
value={todate as ISODateString}
min={fromdate as ISODateString}
max={todayStr as ISODateString}
onChange={(v) => { if (v) setTodate(v); }}
/>
</div>
<span className="inline-flex items-center gap-1.5 rounded-full font-extrabold"
style={{ padding: '6px 12px', fontSize: 12, background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}`, color: BRAND }}>
@@ -220,90 +248,92 @@ export default function OrdersDeliveriesView({
<div className="w-full lg:w-64">
<SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders (Ctrl+K)…" inputRef={searchRef} />
</div>
<button
<Button
label="CSV"
size="sm"
icon={<Download size={13} />}
isDisabled={rows.length === 0}
onClick={exportCsv}
disabled={rows.length === 0}
title="Export current view to CSV"
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap shrink-0"
style={{ padding: '8px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
>
<Download size={13} /> CSV
</button>
/>
</div>
</div>
</FilterBar>
{/* Table */}
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
<ShellCard className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 900 }}>
<thead>
<tr>
<Table style={{ minWidth: 900 }}>
<TableHeader>
<TableRow>
{['#', 'Order', 'Branch', 'Pickup', 'Drop', 'Qty', 'COD', 'Amount', 'Status', ''].map((h, i) => (
<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>
<TableHeaderCell key={i}>{h}</TableHeaderCell>
))}
</tr>
</thead>
<tbody>
</TableRow>
</TableHeader>
<TableBody>
{allOrdersQ.isLoading ? (
<tr><td colSpan={10} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}>
<span className="inline-flex items-center gap-2 text-xs font-semibold">
<Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading orders…
</span>
</td></tr>
<TableRow><TableCell colSpan={10}>
<div className="py-8 flex justify-center"><Spinner label="Loading orders…" /></div>
</TableCell></TableRow>
) : pageRows.length === 0 ? (
<tr><td colSpan={10} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>
No {status} orders found for this date range or search.
</td></tr>
<TableRow><TableCell colSpan={10}>
<EmptyState
isCompact
icon={<Package size={26} />}
title={`No ${status} orders found`}
description="Nothing matches this date range or search."
/>
</TableCell></TableRow>
) : (
pageRows.map((r, i) => {
const st = fstr(r.orderstatus).toLowerCase();
const cod = fnum(r.collectionamt);
const amount = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
return (
<tr
key={fstr(r.orderid) || fstr(r.orderheaderid) || i}
className="transition-colors align-top"
style={{ borderBottom: `1px solid ${DIVIDER}` }}
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td>
<td className="px-3 py-2.5">
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
// Keyed on orderheaderid first. `orderid` is not unique in
// this tenant's data — the same defect fixed in OrdersView,
// which reads the same rows through the same endpoint.
<TableRow key={fstr(r.orderheaderid) || fstr(r.orderid) || i}>
<TableCell><span className="font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</span></TableCell>
<TableCell>
<p className="font-extrabold font-mono text-[13px]">{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
</td>
<td className="px-3 py-2.5">
</TableCell>
<TableCell>
<span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}>
<MapPin size={11} /> {fstr(r.applocation) || '—'}
</span>
{fstr(r.locationname) && <p className="text-[10px] truncate max-w-[120px]" style={{ color: TEXT_2 }}>{fstr(r.locationname)}</p>}
</td>
<td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[140px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
</TableCell>
<TableCell>
<p className="font-bold text-[12px] truncate max-w-[140px]">{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
<p className="text-[10px] truncate max-w-[140px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
</td>
<td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[140px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
</TableCell>
<TableCell>
<p className="font-bold text-[12px] truncate max-w-[140px]">{fstr(r.deliverycustomer) || '—'}</p>
<p className="text-[10px] truncate max-w-[140px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
</td>
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td>
<td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: cod > 0 ? TEXT : TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'}</td>
<td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: amount > 0 ? TEXT : TEXT_3 }}>{amount > 0 ? `₹${amount.toLocaleString('en-IN')}` : '—'}</td>
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
<td className="px-3 py-2.5 text-right">
<button onClick={() => setDetailOrder(r)}
className="rounded-full font-extrabold cursor-pointer transition-colors"
style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>
View
</button>
</td>
</tr>
</TableCell>
<TableCell><span className="font-mono text-[12px]">{fnum(r.quantity) || '—'}</span></TableCell>
<TableCell><span className="font-mono text-[12px] font-semibold" style={cod > 0 ? undefined : { color: TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'}</span></TableCell>
<TableCell><span className="font-mono text-[12px] font-semibold" style={amount > 0 ? undefined : { color: TEXT_3 }}>{amount > 0 ? `₹${amount.toLocaleString('en-IN')}` : '—'}</span></TableCell>
<TableCell><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></TableCell>
<TableCell>
<div className="text-right">
<Button
label="View"
aria-label={`View order ${fstr(r.orderid) || fstr(r.orderheaderid)}`}
variant="secondary"
size="sm"
onClick={() => setDetailOrder(r)}
/>
</div>
</TableCell>
</TableRow>
);
})
)}
</tbody>
</table>
</TableBody>
</Table>
</div>
{/* Totals footer */}
@@ -322,16 +352,29 @@ export default function OrdersDeliveriesView({
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: TEXT_2 }}>
Page {pageno} · {pageRows.length} of {rows.length} shown
</span>
{/* Same reasoning as OrdersView: this list is cursor-shaped, so
Astryx's Pagination (which wants a known page count) would have to
invent a total it does not have. */}
<div className="flex items-center gap-2">
<PagerBtn disabled={pageno === 1} onClick={() => setPageno((p) => Math.max(1, p - 1))}>
<ChevronLeft size={13} /> Prev
</PagerBtn>
<PagerBtn disabled={!hasNext} onClick={() => setPageno((p) => p + 1)}>
Next <ChevronRight size={13} />
</PagerBtn>
<Button
label="Prev"
variant="secondary"
size="sm"
icon={<ChevronLeft size={13} />}
isDisabled={pageno === 1}
onClick={() => setPageno((p) => Math.max(1, p - 1))}
/>
<Button
label="Next"
variant="secondary"
size="sm"
endContent={<ChevronRight size={13} />}
isDisabled={!hasNext}
onClick={() => setPageno((p) => p + 1)}
/>
</div>
</div>
</div>
</ShellCard>
{detailOrder && <OrderDetailModal order={detailOrder} onClose={() => setDetailOrder(null)} />}
</div>
@@ -350,16 +393,6 @@ function TotalChip({ label, value, color }: { label: string; value: string; colo
);
}
function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) {
return (
<button onClick={onClick} disabled={disabled}
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
{children}
</button>
);
}
// ── Order details modal ────────────────────────────────────────────────────────
function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void }) {
const orderheaderid = order.orderheaderid ?? order.orderid;
@@ -387,26 +420,23 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
{ label: 'Delivered', field: 'deliverytime' },
];
return createPortal(
<div
className="fixed inset-0 z-[200] flex items-center justify-center p-4"
style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
className="bg-white max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200"
style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}
>
{/* Brand accent bar */}
<div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)` }} />
// PHASE 6 — the portal and the `width: min(32rem, 92vw)` are gone with the
// overlay. Both existed because a `fixed inset-0` resolves against the nearest
// transformed ancestor rather than the viewport, and this page has several, so
// the panel used to collapse. Dialog renders in the browser's top layer, which
// no ancestor can contain.
return (
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
<div className="flex flex-col max-h-[90vh]">
{/* Modal header */}
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}>
<Package size={16} style={{ color: BRAND }} />
Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}
</h4>
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button>
<div className="p-4 flex justify-between items-center shrink-0">
<Heading level={3}>
<span className="flex items-center gap-2">
<Package size={16} style={{ color: BRAND }} />
Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}
</span>
</Heading>
<IconButton label="Close order details" variant="ghost" size="sm" icon={<X size={16} />} onClick={onClose} />
</div>
{/* Body */}
@@ -498,14 +528,10 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
</div>
{/* Footer */}
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white"
style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}>
Close
</button>
<div className="p-3 flex justify-end shrink-0">
<Button label="Close" onClick={onClose} />
</div>
</div>
</div>,
document.body,
</Dialog>
);
}

View File

@@ -13,7 +13,7 @@
import React, { useMemo, useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2, Download, UserCheck, ClipboardList, ArrowLeft } from 'lucide-react';
import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Download, UserCheck, ClipboardList, ArrowLeft } from 'lucide-react';
import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails, useFiestaRiders, useFiestaAssignRider, useFiestaNotifyRider } from '../services/fiestaQueries';
import {
FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, RIDER_MESSAGES, RiderNotReachableError,
@@ -21,9 +21,26 @@ import {
} from '../services/fiestaApi';
import { shortTime } from '../services/fiestaMappers';
import {
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
ORDER_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge, ring,
KpiStrip, Pill, StatusChip, SearchPill, FilterBar, Card as ShellCard,
ORDER_STATUS, statusColor, BRAND, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge, ring,
} from './consoleUi';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Dialog } from '@astryxdesign/core/Dialog';
import { DateInput } from '@astryxdesign/core/DateInput';
// DateInput's value is a branded `YYYY-MM-DD` template-literal type, while our
// dates are plain strings out of `ymd()`. Importing the real type keeps the cast
// honest and localised instead of reaching for `as any` at four call sites.
import type { ISODateString } from '@astryxdesign/core/utils';
import { Selector } from '@astryxdesign/core/Selector';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Heading } from '@astryxdesign/core/Heading';
import { Spinner } from '@astryxdesign/core/Spinner';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table';
interface OrdersViewProps {
searchQuery?: string;
@@ -357,12 +374,28 @@ export default function OrdersView({
</React.Fragment>
))}
</div>
{/* PHASE 6 — two bare `<input type="date">` become DateInputs. Both
were unlabelled, so a screen reader announced only "date"; and the
native picker was the only way in, with no keyboard entry format
hint. The min/max cross-guard between the two is preserved. */}
<div className="flex items-center gap-2 text-xs">
<input type="date" value={fromdate} max={todate} onChange={(e) => setScope({ from: e.target.value })}
className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} />
<DateInput
label="From date"
isLabelHidden
size="sm"
value={fromdate as ISODateString}
max={todate as ISODateString}
onChange={(v) => { if (v) setScope({ from: v }); }}
/>
<span style={{ color: TEXT_3 }}>→</span>
<input type="date" value={todate} min={fromdate} onChange={(e) => setScope({ to: e.target.value })}
className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} />
<DateInput
label="To date"
isLabelHidden
size="sm"
value={todate as ISODateString}
min={fromdate as ISODateString}
onChange={(v) => { if (v) setScope({ to: v }); }}
/>
</div>
</div>
</FilterBar>
@@ -386,27 +419,28 @@ export default function OrdersView({
</div>
<div className="flex items-center gap-2 w-full lg:w-auto lg:shrink-0">
{branches.length > 1 && (
<select
value={branch}
onChange={(e) => { setBranch(Number(e.target.value)); setPageno(1); }}
title="Filter by branch / app-location"
className="rounded-full font-bold text-xs outline-none cursor-pointer shrink-0"
style={{ padding: '7px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }}
>
<option value={0}>All branches</option>
{branches.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
</select>
<div className="shrink-0 min-w-[150px]">
<Selector
label="Filter by branch"
isLabelHidden
size="sm"
value={String(branch)}
onChange={(v) => { setBranch(Number(v) || 0); setPageno(1); }}
options={[
{ value: '0', label: 'All branches' },
...branches.map((b) => ({ value: String(b.id), label: b.name })),
]}
/>
</div>
)}
<div className="w-full lg:w-60"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders (Ctrl+K)…" inputRef={searchRef} /></div>
<button
<Button
label="CSV"
size="sm"
icon={<Download size={13} />}
isDisabled={rows.length === 0}
onClick={exportCsv}
disabled={rows.length === 0}
title="Export current view to CSV"
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap shrink-0"
style={{ padding: '8px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
>
<Download size={13} /> CSV
</button>
/>
</div>
</div>
</FilterBar>
@@ -417,131 +451,170 @@ export default function OrdersView({
<span className="inline-flex items-center gap-1.5 font-extrabold text-xs" style={{ color: BRAND }}>
<UserCheck size={15} /> {selected.size} selected
</span>
<div className="flex bg-white rounded-full p-0.5 ml-2 border" style={{ borderColor: edge(BRAND) }}>
<button
onClick={() => setRiderSource('all')}
title="Every rider on duty at this branch right now"
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors ${riderSource === 'all' ? '' : 'text-slate-500 hover:bg-slate-50'}`}
style={riderSource === 'all' ? { background: tint(BRAND), color: BRAND } : undefined}
{/* PHASE 6 — the two-button rider-source toggle is a SegmentedControl.
It was a pair of buttons that looked like a segmented control but
announced as two unrelated buttons with no indication which was
active; the real one exposes the selection to assistive tech. */}
<div className="ml-2">
<SegmentedControl
label="Rider source"
size="sm"
value={riderSource}
onChange={(v) => setRiderSource(v as 'all' | 'partner')}
>
On Duty
</button>
<button
onClick={() => setRiderSource('partner')}
disabled={!orderPartnerId}
title={orderPartnerId ? 'Only riders from the partner carrying these orders' : 'These orders name no partner'}
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors disabled:opacity-40 ${riderSource === 'partner' ? '' : 'text-slate-500 hover:bg-slate-50'}`}
style={riderSource === 'partner' ? { background: tint(BRAND), color: BRAND } : undefined}
>
This Partner
</button>
<SegmentedControlItem value="all" label="On Duty" />
<SegmentedControlItem value="partner" label="This Partner" isDisabled={!orderPartnerId} />
</SegmentedControl>
</div>
<select
value={assignRiderId}
onChange={(e) => setAssignRiderId(Number(e.target.value))}
disabled={selected.size === 0}
title="Choose a rider to assign"
className="rounded-full font-bold text-xs outline-none cursor-pointer disabled:opacity-50"
style={{ padding: '7px 12px', border: `1.5px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 260 }}
>
<option value={0}>
{ridersQ.isLoading
? 'Loading riders…'
: riderOptions.length
? 'Select rider…'
: riderSource === 'partner'
? 'No riders on duty for this partner'
: 'No riders on duty right now'}
</option>
{riderOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<button
{/* The empty-list wording carried real information — which of the two
scopes came back empty — so it moves to `placeholder` rather than
being flattened into a generic "Select…". */}
<div className="min-w-[200px] max-w-[260px]">
<Selector
label="Choose a rider to assign"
isLabelHidden
size="sm"
value={assignRiderId ? String(assignRiderId) : ''}
onChange={(v) => setAssignRiderId(Number(v) || 0)}
isDisabled={selected.size === 0}
isLoading={ridersQ.isLoading}
placeholder={
ridersQ.isLoading
? 'Loading riders…'
: riderOptions.length
? 'Select rider…'
: riderSource === 'partner'
? 'No riders on duty for this partner'
: 'No riders on duty right now'
}
options={riderOptions.map((o) => ({ value: String(o.id), label: o.label }))}
/>
</div>
<Button
label="Assign rider"
size="sm"
icon={<UserCheck size={13} />}
isLoading={assignMut.isPending}
isDisabled={!assignRiderId || selected.size === 0 || assignMut.isPending}
onClick={handleAssign}
disabled={!assignRiderId || selected.size === 0 || assignMut.isPending}
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
style={{ padding: '7px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
>
{assignMut.isPending ? <Loader2 size={13} className="animate-spin" /> : <UserCheck size={13} />} Assign rider
</button>
/>
{selected.size > 0 && (
<button onClick={() => setSelected(new Set())} className="rounded-full font-bold text-xs cursor-pointer" style={{ padding: '7px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
Clear
</button>
<Button label="Clear" variant="secondary" size="sm" onClick={() => setSelected(new Set())} />
)}
{assignMsg && <span className="text-[11px] font-semibold ml-auto" style={{ color: TEXT_2 }}>{assignMsg}</span>}
</div>
)}
{/* Table */}
<div className="bg-white border overflow-hidden" style={{ borderColor: BORDER }}>
{/* Table
PHASE 6 — raw <table> onto the Astryx primitives, and the three bare
`<input type="checkbox">` onto CheckboxInput. The row boxes carried an
aria-label but no visible label and no focus ring beyond the browser
default; the disabled one explained itself only through `title`, which
a keyboard user never sees. */}
<ShellCard className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 960 }}>
<thead>
<tr>
<th className="px-3 py-2.5 text-left" style={TH_STYLE}>
<input type="checkbox" checked={allSelected} onChange={toggleAll} disabled={assignableRows.length === 0} aria-label="Select all assignable orders" style={{ accentColor: BRAND, cursor: 'pointer', width: 15, height: 15 }} />
</th>
<Table style={{ minWidth: 960 }}>
<TableHeader>
<TableRow>
<TableHeaderCell>
<CheckboxInput
label="Select all assignable orders"
isLabelHidden
value={allSelected}
isDisabled={assignableRows.length === 0}
onChange={toggleAll}
/>
</TableHeaderCell>
{['#', 'Order', 'Branch', 'Pickup', 'Drop', 'Qty', 'COD', 'KMs', 'Charges', 'Status', ''].map((h, i) => (
<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>
<TableHeaderCell key={i}>{h}</TableHeaderCell>
))}
</tr>
</thead>
<tbody>
</TableRow>
</TableHeader>
<TableBody>
{ordersQ.isLoading ? (
<tr><td colSpan={12} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}>
<span className="inline-flex items-center gap-2 text-xs font-semibold"><Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading orders…</span>
</td></tr>
<TableRow><TableCell colSpan={12}>
<div className="py-8 flex justify-center"><Spinner label="Loading orders…" /></div>
</TableCell></TableRow>
) : rows.length === 0 ? (
<tr><td colSpan={12} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No orders found for this status, date range, or search.</td></tr>
<TableRow><TableCell colSpan={12}>
<EmptyState
isCompact
icon={<Package size={26} />}
title="No orders found"
description="Nothing matches this status, date range, or search."
/>
</TableCell></TableRow>
) : (
rows.map((r, i) => {
const st = fstr(r.orderstatus).toLowerCase();
const cod = fnum(r.collectionamt);
const charges = fnum(r.deliverycharge) || fnum(r.deliverycharges);
const isSel = selected.has(rowKey(r));
return (
<tr key={fstr(r.orderid) || i} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER_C}`, background: selected.has(rowKey(r)) ? tint(BRAND) : 'transparent' }}
onMouseEnter={(e) => { if (!selected.has(rowKey(r))) e.currentTarget.style.background = SURFACE_ALT; }} onMouseLeave={(e) => { e.currentTarget.style.background = selected.has(rowKey(r)) ? tint(BRAND) : 'transparent'; }}>
<td className="px-3 py-2.5">
// `rowKey`, not `orderid`. `orderid` is NOT unique — this
// tenant returns several rows sharing one (three "1135-1" on
// the store console alone), so React logged a duplicate-key
// error for most of the page and was free to duplicate or
// drop rows. `rowKey` prefers `orderheaderid`, the actual
// primary key, and is already what the selection Set uses —
// so row identity and checkbox identity now agree instead of
// drifting apart on re-render.
//
// The selected tint stays inline: it is a brand wash keyed to
// selection state, which Table has no prop for.
<TableRow key={rowKey(r) || i} style={isSel ? { background: tint(BRAND) } : undefined}>
<TableCell>
{st === 'created' ? (
<input type="checkbox" checked={selected.has(rowKey(r))} onChange={() => toggleRow(rowKey(r))} aria-label="Select order" style={{ accentColor: BRAND, cursor: 'pointer', width: 15, height: 15 }} />
<CheckboxInput
label="Select order"
isLabelHidden
value={isSel}
onChange={() => toggleRow(rowKey(r))}
/>
) : (
<input type="checkbox" disabled aria-label="Order cannot be assigned" style={{ width: 15, height: 15, opacity: 0.3 }} title="Only 'created' orders can be assigned" />
<CheckboxInput
label="Only 'created' orders can be assigned"
isLabelHidden
value={false}
isDisabled
/>
)}
</td>
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td>
<td className="px-3 py-2.5">
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
</TableCell>
<TableCell><span className="font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</span></TableCell>
<TableCell>
<p className="font-extrabold font-mono text-[13px]">{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
</td>
<td className="px-3 py-2.5">
</TableCell>
<TableCell>
<span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}>
<MapPin size={11} /> {fstr(r.applocation) || '—'}
</span>
{fstr(r.locationname) && <p className="text-[10px] truncate max-w-[130px]" style={{ color: TEXT_2 }}>{fstr(r.locationname)}</p>}
</td>
<td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
</TableCell>
<TableCell>
<p className="font-bold text-[12px] truncate max-w-[150px]">{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
</td>
<td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
</TableCell>
<TableCell>
<p className="font-bold text-[12px] truncate max-w-[150px]">{fstr(r.deliverycustomer) || '—'}</p>
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
</td>
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td>
<td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: cod > 0 ? TEXT : TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'}</td>
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: fnum(r.kms) ? TEXT_2 : TEXT_3 }}>{fnum(r.kms) ? fnum(r.kms).toFixed(1) : '—'}</td>
<td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: charges > 0 ? TEXT : TEXT_3 }}>{charges > 0 ? `₹${charges.toLocaleString('en-IN')}` : '—'}</td>
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
<td className="px-3 py-2.5 text-right">
<button onClick={() => setDetailOrder(r)} className="rounded-full font-extrabold cursor-pointer transition-colors"
style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>View</button>
</td>
</tr>
</TableCell>
<TableCell><span className="font-mono text-[12px]">{fnum(r.quantity) || '—'}</span></TableCell>
<TableCell><span className="font-mono text-[12px] font-semibold" style={cod > 0 ? undefined : { color: TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'}</span></TableCell>
<TableCell><span className="font-mono text-[12px]" style={{ color: fnum(r.kms) ? TEXT_2 : TEXT_3 }}>{fnum(r.kms) ? fnum(r.kms).toFixed(1) : '—'}</span></TableCell>
<TableCell><span className="font-mono text-[12px] font-semibold" style={charges > 0 ? undefined : { color: TEXT_3 }}>{charges > 0 ? `₹${charges.toLocaleString('en-IN')}` : '—'}</span></TableCell>
<TableCell><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></TableCell>
<TableCell>
<div className="text-right">
<Button label="View" variant="secondary" size="sm" onClick={() => setDetailOrder(r)} />
</div>
</TableCell>
</TableRow>
);
})
)}
</tbody>
</table>
</TableBody>
</Table>
</div>
{/* Totals across the filtered rows */}
{rows.length > 0 && (
@@ -554,12 +627,30 @@ export default function OrdersView({
)}
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: TEXT_2 }}>Page {pageno} · {rows.length} shown</span>
{/* Astryx `Pagination` wants a known page count; this list is
cursor-shaped (hasNext is inferred from a full page), so Prev/Next
stay two Buttons rather than a pager that would have to invent a
total it does not have. */}
<div className="flex items-center gap-2">
<PagerBtn disabled={pageno === 1} onClick={() => setPageno((p) => Math.max(1, p - 1))}><ChevronLeft size={13} /> Prev</PagerBtn>
<PagerBtn disabled={!hasNext} onClick={() => setPageno((p) => p + 1)}>Next <ChevronRight size={13} /></PagerBtn>
<Button
label="Prev"
variant="secondary"
size="sm"
icon={<ChevronLeft size={13} />}
isDisabled={pageno === 1}
onClick={() => setPageno((p) => Math.max(1, p - 1))}
/>
<Button
label="Next"
variant="secondary"
size="sm"
endContent={<ChevronRight size={13} />}
isDisabled={!hasNext}
onClick={() => setPageno((p) => p + 1)}
/>
</div>
</div>
</div>
</ShellCard>
{detailOrder && <OrderDetailModal order={detailOrder} onClose={() => setDetailOrder(null)} />}
@@ -568,38 +659,37 @@ export default function OrdersView({
Opens the full-page review/assign view on click. */}
{status === 'created' && selected.size > 1 && !showSelected &&
createPortal(
<button
onClick={() => setShowSelected(true)}
title={`Review & assign ${selected.size} selected order${selected.size === 1 ? '' : 's'}`}
className="group fixed right-0 z-[150] flex items-center gap-2 py-3 pl-4 pr-5 text-white font-extrabold text-xs cursor-pointer transition-all duration-200 hover:pr-7 animate-in slide-in-from-right-4"
style={{ top: '70%', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, borderTopLeftRadius: 9999, borderBottomLeftRadius: 9999, boxShadow: `0 10px 30px ${ring(BRAND)}` }}
>
<span className="relative inline-flex">
<ClipboardList size={18} />
<span className="absolute -top-2.5 -right-2.5 min-w-[17px] h-[17px] px-1 rounded-full bg-rose-500 text-[9px] font-black flex items-center justify-center ring-2 ring-white">{selected.size}</span>
</span>
<span className="max-w-0 overflow-hidden group-hover:max-w-[80px] transition-all duration-200 whitespace-nowrap">Review</span>
</button>,
// PHASE 6 — the label used to be collapsed to `max-w-0` and revealed
// only on hover, so the button read as an unlabelled icon until you
// pointed at it and was invisible to anyone not using a mouse. The
// Astryx Button carries the count in its label instead.
<div className="fixed right-4 z-[150] animate-in slide-in-from-right-4" style={{ top: '70%' }}>
<Button
label={`Review ${selected.size} selected`}
icon={<ClipboardList size={16} />}
onClick={() => setShowSelected(true)}
/>
</div>,
document.body,
)}
{showSelected &&
createPortal(
<SelectedOrdersPage
rows={selectedRows}
rowKey={rowKey}
riderOptions={riderOptions}
ridersLoading={ridersQ.isLoading}
assignRiderId={assignRiderId}
setAssignRiderId={setAssignRiderId}
assigning={assignMut.isPending}
assignMsg={assignMsg}
onAssign={handleAssign}
onRemove={(k) => toggleRow(k)}
onClose={() => setShowSelected(false)}
/>,
document.body,
)}
{/* No createPortal any more — Dialog renders in the browser's top layer,
which is already outside every ancestor's containing block. */}
{showSelected && (
<SelectedOrdersPage
rows={selectedRows}
rowKey={rowKey}
riderOptions={riderOptions}
ridersLoading={ridersQ.isLoading}
assignRiderId={assignRiderId}
setAssignRiderId={setAssignRiderId}
assigning={assignMut.isPending}
assignMsg={assignMsg}
onAssign={handleAssign}
onRemove={(k) => toggleRow(k)}
onClose={() => setShowSelected(false)}
/>
)}
</div>
);
}
@@ -623,75 +713,114 @@ function SelectedOrdersPage({
onClose: () => void;
}) {
return (
<div className="fixed inset-0 z-[200] overflow-y-auto animate-in fade-in duration-200" style={{ background: '#f8fafc' }}>
{/* Sticky page header with the assign controls */}
<div className="sticky top-0 z-10 border-b" style={{ background: '#fff', borderColor: BORDER }}>
<div className="max-w-5xl mx-auto px-4 md:px-8 py-4 flex flex-wrap items-center gap-3">
<button onClick={onClose} className="inline-flex items-center gap-1.5 rounded-full font-bold text-xs cursor-pointer" style={{ padding: '8px 14px', border: `1px solid ${BORDER}`, color: TEXT_2, background: '#fff' }}>
<ArrowLeft size={14} /> Back to orders
</button>
<div className="flex items-center gap-2">
<span className="h-9 w-9 rounded-xl flex items-center justify-center" style={{ background: tint(BRAND), color: BRAND }}><ClipboardList size={18} /></span>
<div>
<h1 className="font-bold text-lg tracking-tight leading-none" style={{ color: TEXT }}>Selected Orders</h1>
<p className="text-[11px] mt-1" style={{ color: TEXT_2 }}>{rows.length} order{rows.length === 1 ? '' : 's'} ready to assign</p>
// PHASE 6 — was a `fixed inset-0` page takeover with no Escape and no focus
// trap: tabbing from it walked straight back into the orders board
// underneath. Dialog's fullscreen variant gives both, and keeps the same
// full-bleed shape.
//
// What it does NOT give is focus restore — verified in Chrome: closing this
// leaves focus on <body>, not on the control that opened it. consoleUi's
// SlideDrawer restores it because it does so by hand. That gap is Astryx's,
// it applies to every Dialog in the app, and it is a missing gain rather
// than a regression (the old overlay managed no focus at all), so it is not
// patched here one screen at a time.
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} variant="fullscreen" purpose="info">
<div className="h-full overflow-y-auto">
{/* Sticky page header with the assign controls */}
<div className="sticky top-0 z-10 border-b" style={{ background: 'var(--color-background-card, #fff)', borderColor: BORDER }}>
<div className="max-w-5xl mx-auto px-4 md:px-8 py-4 flex flex-wrap items-center gap-3">
<Button label="Back to orders" variant="secondary" size="sm" icon={<ArrowLeft size={14} />} onClick={onClose} />
<div className="flex items-center gap-2">
<span className="h-9 w-9 rounded-xl flex items-center justify-center" style={{ background: tint(BRAND), color: BRAND }}><ClipboardList size={18} /></span>
<div>
<Heading level={2}>Selected Orders</Heading>
<p className="text-[11px] mt-1" style={{ color: TEXT_2 }}>{rows.length} order{rows.length === 1 ? '' : 's'} ready to assign</p>
</div>
</div>
<div className="flex items-center gap-2 ml-auto">
<div className="min-w-[200px] max-w-[260px]">
<Selector
label="Choose a rider to assign"
isLabelHidden
size="sm"
value={assignRiderId ? String(assignRiderId) : ''}
onChange={(v) => setAssignRiderId(Number(v) || 0)}
isLoading={ridersLoading}
placeholder={ridersLoading ? 'Loading riders…' : riderOptions.length ? 'Select rider…' : 'No riders available'}
options={riderOptions.map((o) => ({ value: String(o.id), label: o.label }))}
/>
</div>
<Button
label="Assign rider"
size="sm"
icon={<UserCheck size={14} />}
isLoading={assigning}
isDisabled={!assignRiderId || rows.length === 0 || assigning}
onClick={onAssign}
/>
</div>
</div>
<div className="flex items-center gap-2 ml-auto">
<select value={assignRiderId} onChange={(e) => setAssignRiderId(Number(e.target.value))} className="rounded-full font-bold text-xs outline-none cursor-pointer" style={{ padding: '8px 12px', border: `1.5px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 260 }}>
<option value={0}>{ridersLoading ? 'Loading riders…' : riderOptions.length ? 'Select rider…' : 'No riders available'}</option>
{riderOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<button onClick={onAssign} disabled={!assignRiderId || rows.length === 0 || assigning} className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed" style={{ padding: '8px 16px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}>
{assigning ? <Loader2 size={14} className="animate-spin" /> : <UserCheck size={14} />} Assign rider
</button>
</div>
</div>
</div>
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6">
{assignMsg && <div className="mb-4 rounded-xl px-4 py-2.5 text-xs font-semibold" style={{ background: tint(BRAND), border: `1px solid ${edge(BRAND)}`, color: BRAND }}>{assignMsg}</div>}
{rows.length === 0 ? (
<div className="bg-white border rounded-2xl p-12 text-center text-xs" style={{ borderColor: BORDER, color: TEXT_3 }}>
No orders selected. <button onClick={onClose} className="font-bold underline cursor-pointer" style={{ color: BRAND }}>Go back</button>
</div>
) : (
<div className="bg-white border overflow-hidden" style={{ borderColor: BORDER }}>
<div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 720 }}>
<thead><tr>{['#', 'Order', 'Pickup', 'Drop', 'Status', ''].map((h, i) => <th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>)}</tr></thead>
<tbody>
{rows.map((r, i) => {
const st = fstr(r.orderstatus).toLowerCase();
return (
<tr key={rowKey(r) || i} style={{ borderBottom: `1px solid ${DIVIDER_C}` }}>
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td>
<td className="px-3 py-2.5">
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
</td>
<td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[180px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
</td>
<td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[180px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
</td>
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
<td className="px-3 py-2.5 text-right">
<button onClick={() => onRemove(rowKey(r))} title="Remove from selection" className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={15} /></button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6">
{assignMsg && <div className="mb-4 rounded-xl px-4 py-2.5 text-xs font-semibold" style={{ background: tint(BRAND), border: `1px solid ${edge(BRAND)}`, color: BRAND }}>{assignMsg}</div>}
{rows.length === 0 ? (
<ShellCard className="p-12">
<EmptyState
title="No orders selected"
actions={<Button label="Go back" variant="secondary" onClick={onClose} />}
/>
</ShellCard>
) : (
<ShellCard className="overflow-hidden">
<div className="overflow-x-auto">
<Table style={{ minWidth: 720 }}>
<TableHeader>
<TableRow>
{['#', 'Order', 'Pickup', 'Drop', 'Status', ''].map((h, i) => <TableHeaderCell key={i}>{h}</TableHeaderCell>)}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r, i) => {
const st = fstr(r.orderstatus).toLowerCase();
return (
<TableRow key={rowKey(r) || i}>
<TableCell><span className="font-mono" style={{ color: TEXT_3 }}>{i + 1}</span></TableCell>
<TableCell>
<p className="font-extrabold font-mono text-[13px]">{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
</TableCell>
<TableCell>
<p className="font-bold text-[12px] truncate max-w-[180px]">{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
</TableCell>
<TableCell>
<p className="font-bold text-[12px] truncate max-w-[180px]">{fstr(r.deliverycustomer) || '—'}</p>
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
</TableCell>
<TableCell><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></TableCell>
<TableCell>
<div className="text-right">
<IconButton
label={`Remove order ${fstr(r.orderid) || fstr(r.orderheaderid)} from selection`}
variant="ghost"
size="sm"
icon={<X size={15} />}
onClick={() => onRemove(rowKey(r))}
/>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</ShellCard>
)}
</div>
</div>
</div>
</Dialog>
);
}
@@ -704,16 +833,6 @@ function TotalChip({ label, value, color }: { label: string; value: string; colo
);
}
function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) {
return (
<button onClick={onClick} disabled={disabled}
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
{children}
</button>
);
}
// ── Order details modal ─────────────────────────────────────────────────────────
function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void }) {
const orderheaderid = order.orderheaderid ?? order.orderid;
@@ -726,39 +845,42 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
const st = fstr(order.orderstatus).toLowerCase();
const total = fnum(order.deliveryamt) || fnum(order.orderamount);
// Portal to <body> so the overlay escapes any transformed / blurred / overflow
// ancestor in the view tree — otherwise `fixed inset-0` resolves against that
// ancestor (not the viewport) and the panel collapses to a sliver. The explicit
// viewport-relative width is a belt-and-suspenders so sizing never depends on
// percentage resolution against a broken containing block.
return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="bg-white max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
<div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${soft(BRAND)} 100%)` }} />
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Package size={16} style={{ color: BRAND }} /> Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}</h4>
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button>
// PHASE 6 — the hand-rolled overlay is gone, and with it the portal and the
// `width: min(32rem, 92vw)` workaround. Both existed for one reason: a
// `fixed inset-0` resolves against the nearest transformed/filtered ancestor
// rather than the viewport, and this page has several, so the panel used to
// collapse to a sliver. Dialog renders in the browser's top layer, which no
// ancestor can contain — so the escape hatches are no longer needed.
return (
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
<div className="flex flex-col max-h-[90vh]">
<div className="p-4 flex justify-between items-center shrink-0">
<Heading level={3}>
<span className="flex items-center gap-2">
<Package size={16} style={{ color: BRAND }} /> Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}
</span>
</Heading>
<IconButton label="Close" variant="ghost" size="sm" icon={<X size={16} />} onClick={onClose} />
</div>
<div className="p-4 space-y-4 overflow-y-auto flex-1">
<div className="flex items-center justify-between">
<StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} />
<span className="text-[11px] font-medium" style={{ color: TEXT_2 }}>{shortTime(order.orderdate || order.deliverydate)}</span>
</div>
<div className="p-3 rounded-xl space-y-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
<div className="flex items-center gap-2 font-bold" style={{ color: TEXT }}>{fstr(order.deliverycustomer) || 'Customer'}</div>
<div className="p-3 rounded-xl space-y-1.5" style={{ background: 'rgba(127,127,127,0.06)' }}>
<div className="flex items-center gap-2 font-bold">{fstr(order.deliverycustomer) || 'Customer'}</div>
{fstr(order.deliverycontactno) && <div className="flex items-center gap-2 font-mono text-xs" style={{ color: TEXT_2 }}><Phone size={12} /> {fstr(order.deliverycontactno)}</div>}
<div className="flex items-start gap-2 text-xs" style={{ color: TEXT_2 }}><MapPin size={12} className="mt-0.5 shrink-0" /> <span className="leading-relaxed">{fstr(order.deliveryaddress) || fstr(order.deliverysuburb) || 'Address unavailable'}</span></div>
</div>
<div>
<span className="text-[10px] font-extrabold uppercase tracking-wide block mb-2" style={{ color: TEXT_2 }}>Order Items</span>
<div className="rounded-xl p-3" style={{ background: 'rgba(248,250,252,0.6)', border: `1px solid ${BORDER}` }}>
{detailsQ.isLoading && <div className="py-2 flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_3 }}><Loader2 size={12} className="animate-spin" /> Loading line items…</div>}
<div className="rounded-xl p-3" style={{ background: 'rgba(127,127,127,0.04)' }}>
{detailsQ.isLoading && <div className="py-2 flex justify-center"><Spinner size="sm" label="Loading line items…" /></div>}
{!detailsQ.isLoading && lines.length === 0 && <div className="py-2 text-[11px] font-medium" style={{ color: TEXT_3 }}>No line items returned for this order.</div>}
{lines.map((item, idx) => (
<div key={idx} className="py-2 flex justify-between items-center" style={{ borderTop: idx ? `1px solid ${DIVIDER_C}` : undefined }}>
<div><p className="font-bold text-xs" style={{ color: TEXT }}>{item.name}</p><p className="text-[10px]" style={{ color: TEXT_2 }}>Qty: {item.quantity} × ₹{item.price}</p></div>
<span className="font-extrabold font-mono text-xs" style={{ color: TEXT }}>₹{item.lineTotal.toLocaleString('en-IN')}</span>
<div><p className="font-bold text-xs">{item.name}</p><p className="text-[10px]" style={{ color: TEXT_2 }}>Qty: {item.quantity} × ₹{item.price}</p></div>
<span className="font-extrabold font-mono text-xs">₹{item.lineTotal.toLocaleString('en-IN')}</span>
</div>
))}
{total > 0 && (
@@ -769,13 +891,10 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
</div>
</div>
</div>
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT_LOCAL})` }}>Close</button>
<div className="p-3 flex justify-end shrink-0">
<Button label="Close" onClick={onClose} />
</div>
</div>
</div>,
document.body,
</Dialog>
);
}
const BRAND_LIGHT_LOCAL = '#9255AB';

View File

@@ -26,6 +26,13 @@ import { Selector } from '@astryxdesign/core/Selector';
import { Button } from '@astryxdesign/core/Button';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { TimeInput } from '@astryxdesign/core/TimeInput';
import { IconButton } from '@astryxdesign/core/IconButton';
import { ToggleButton } from '@astryxdesign/core/ToggleButton';
import { Badge } from '@astryxdesign/core/Badge';
import { Banner } from '@astryxdesign/core/Banner';
import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table';
import {
AlertTriangle,
Check,
@@ -60,6 +67,7 @@ import {
BORDER,
BRAND,
Card,
Card as ShellCard,
DIVIDER,
Pill,
StatusChip,
@@ -67,7 +75,6 @@ import {
TEXT,
TEXT_2,
TEXT_3,
TH_STYLE,
edge,
tint,
} from './consoleUi';
@@ -443,89 +450,99 @@ export default function PosStaffPanel({
</div>
</Card>
) : (
<div className="bg-white rounded-2xl border overflow-hidden shadow-sm w-full min-w-0" style={{ borderColor: BORDER }}>
<ShellCard className="overflow-hidden w-full min-w-0">
<div className="overflow-x-auto">
<table className="w-full text-sm whitespace-nowrap" style={{ minWidth: 940 }}>
<thead style={TH_STYLE}>
<tr>
<th className="px-4 py-3 text-left">Name</th>
<th className="px-4 py-3 text-left">Role</th>
<th className="px-4 py-3 text-left">Mobile</th>
<th className="px-4 py-3 text-left">Shift</th>
<th className="px-4 py-3 text-left">Username</th>
<th className="px-4 py-3 text-left">PIN</th>
<th className="px-4 py-3 text-left">Password</th>
<th className="px-4 py-3 text-left">Status</th>
<th className="px-4 py-3 text-right">&nbsp;</th>
</tr>
</thead>
<tbody className="divide-y" style={{ borderColor: DIVIDER }}>
{/* whiteSpace: the old <table> carried `whitespace-nowrap`; Astryx's
Table does not, and without it the till username breaks
mid-token ("supervisor.1169@po / s.nearle.in"). The wrapper
above already scrolls horizontally. */}
<Table style={{ minWidth: 940, whiteSpace: 'nowrap' }}>
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Role</TableHeaderCell>
<TableHeaderCell>Mobile</TableHeaderCell>
<TableHeaderCell>Shift</TableHeaderCell>
<TableHeaderCell>Username</TableHeaderCell>
<TableHeaderCell>PIN</TableHeaderCell>
<TableHeaderCell>Password</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell><span className="sr-only">Row actions</span></TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{staff.map((s) => {
const inactive = (s.status || '').toLowerCase() === 'inactive';
const who = s.full_name || `#${s.user_id}`;
return (
<tr key={s.user_id} className="hover:bg-slate-50/70" style={{ opacity: inactive ? 0.55 : 1 }}>
<td className="px-4 py-3 font-bold" style={{ color: TEXT }}>{s.full_name || `#${s.user_id}`}</td>
<td className="px-4 py-3">
<TableRow key={s.user_id} style={{ opacity: inactive ? 0.55 : 1 }}>
<TableCell><span className="font-bold">{who}</span></TableCell>
<TableCell>
<StatusChip
label={s.role || String(s.role_id)}
color={s.role_id === POS_ROLE_CASHIER ? BRAND : '#0ea5e9'}
/>
</td>
<td className="px-4 py-3 font-mono text-[12px]" style={{ color: s.contactno ? TEXT : '#f59e0b' }}>
{s.contactno || 'not set'}
</td>
<td className="px-4 py-3 text-[12px]" style={{ color: TEXT_2 }}>
{s.shift_name ? `${s.shift_name} · ${s.shift_start}–${s.shift_end}` : '—'}
</td>
<td className="px-4 py-3 font-mono text-[12px]" style={{ color: TEXT_2 }}>{s.authname || '—'}</td>
<td className="px-4 py-3 font-mono tracking-widest" style={{ color: TEXT_2 }}>{s.pin || '—'}</td>
<td className="px-4 py-3">
{s.has_password ? (
<span className="inline-flex items-center gap-1 text-[11.5px] font-semibold" style={{ color: '#10b981' }}>
<ShieldCheck size={12} /> Set
</span>
) : (
<span className="inline-flex items-center gap-1 text-[11.5px] font-semibold" style={{ color: '#f59e0b' }}>
<AlertTriangle size={12} /> Not set
</span>
)}
</td>
<td className="px-4 py-3">
</TableCell>
<TableCell>
<span className="font-mono text-[12px]" style={s.contactno ? undefined : { color: '#f59e0b' }}>
{s.contactno || 'not set'}
</span>
</TableCell>
<TableCell>
<span className="text-[12px]" style={{ color: TEXT_2 }}>
{s.shift_name ? `${s.shift_name} · ${s.shift_start}–${s.shift_end}` : '—'}
</span>
</TableCell>
<TableCell><span className="font-mono text-[12px]" style={{ color: TEXT_2 }}>{s.authname || '—'}</span></TableCell>
<TableCell><span className="font-mono tracking-widest" style={{ color: TEXT_2 }}>{s.pin || '—'}</span></TableCell>
<TableCell>
{/* Was two hand-tinted spans. "Set"/"Not set" is a state,
so it reads as success vs warning. */}
{s.has_password
? <Badge variant="success" label="Set" icon={<ShieldCheck size={12} />} />
: <Badge variant="warning" label="Not set" icon={<AlertTriangle size={12} />} />}
</TableCell>
<TableCell>
<StatusChip label={inactive ? 'Inactive' : 'Active'} color={inactive ? TEXT_3 : '#10b981'} />
</td>
<td className="px-4 py-3">
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1.5">
{inactive ? (
<button
<Button
label="Reactivate"
aria-label={`Reactivate ${who}`}
variant="secondary"
size="sm"
isDisabled={updateMut.isPending}
onClick={() => reactivate(s)}
disabled={updateMut.isPending}
className="rounded-full px-3 py-1 text-[11.5px] font-bold cursor-pointer disabled:opacity-50"
style={{ background: tint('#10b981'), color: '#10b981', border: `1px solid ${edge('#10b981')}` }}
>
Reactivate
</button>
/>
) : (
<>
<IconBtn title="Edit" color={BRAND} onClick={() => openEdit(s)}>
{/* Names, not bare verbs. A staff table repeats
"Edit / Reset password / Deactivate" on every
row, so without the person's name a screen
reader hears the same three words N times with
no idea whose account is about to change. */}
<IconBtn title={`Edit ${who}`} color={BRAND} onClick={() => openEdit(s)}>
<Pencil size={13} />
</IconBtn>
<IconBtn title="Reset password" color="#0ea5e9" onClick={() => setConfirmReset(s)}>
<IconBtn title={`Reset password for ${who}`} color="#0ea5e9" onClick={() => setConfirmReset(s)}>
<KeyRound size={13} />
</IconBtn>
<IconBtn title="Deactivate" color="#ef4444" onClick={() => setConfirmOff(s)}>
<IconBtn title={`Deactivate ${who}`} color="#ef4444" onClick={() => setConfirmOff(s)}>
<Trash2 size={13} />
</IconBtn>
</>
)}
</div>
</td>
</tr>
</TableCell>
</TableRow>
);
})}
</tbody>
</table>
</TableBody>
</Table>
</div>
</div>
</ShellCard>
)}
{rowError && (
@@ -595,37 +612,28 @@ export default function PosStaffPanel({
anyone signed in at a counter with it will be refused on their next sign-in.
</p>
<div className="flex flex-wrap items-center gap-2 mt-4">
<button
<Button
label={updateMut.isPending ? 'Resetting…' : 'Reset password'}
isLoading={updateMut.isPending}
isDisabled={updateMut.isPending}
onClick={() => resetPassword(confirmReset)}
disabled={updateMut.isPending}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50"
style={{ background: '#0ea5e9' }}
>
{updateMut.isPending ? 'Resetting…' : 'Reset password'}
</button>
<button
onClick={() => setConfirmReset(null)}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
>
Cancel
</button>
/>
<Button label="Cancel" variant="secondary" onClick={() => setConfirmReset(null)} />
</div>
</Modal>
)}
{/* One-time credentials. The password is not recoverable after this. */}
{issued && (
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-slate-900/45" onClick={() => setIssued(null)} />
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6" style={{ border: `1px solid ${BORDER}`, minWidth: 'min(20rem, 100%)' }}>
<div className="flex items-center gap-2 mb-1">
<KeyRound size={16} style={{ color: BRAND }} />
<h3 className="text-[16px] font-extrabold" style={{ color: TEXT }}>Account created</h3>
</div>
<p className="text-[12.5px] mb-4" style={{ color: '#b45309' }}>
This password is shown once and cannot be looked up again. Copy it now.
</p>
// PHASE 6 — the last hand-rolled overlay on this page, and the one that
// mattered most: it shows a password that exists nowhere else. It now
// goes through the same Modal as edit / reset / deactivate, so it gains
// Escape and a focus trap like the rest.
<Modal onClose={() => setIssued(null)} title="Account created">
<div>
<Banner status="warning" title="Copy this password now">
It is shown once and cannot be looked up again.
</Banner>
<div className="rounded-xl p-3 space-y-2" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
<Field label="Name" value={issued.full_name} />
@@ -644,24 +652,19 @@ export default function PosStaffPanel({
)}
<div className="flex flex-wrap items-center gap-2 mt-4">
<button
<Button
label={copied ? 'Copied' : 'Copy all'}
icon={copied ? <Check size={13} /> : <Copy size={13} />}
onClick={copyCredentials}
className="inline-flex items-center gap-1.5 rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer"
style={{ background: BRAND }}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? 'Copied' : 'Copy all'}
</button>
<button
/>
<Button
label={copied ? 'Done' : 'Close without copying'}
variant="secondary"
onClick={() => setIssued(null)}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
>
{copied ? 'Done' : 'Close without copying'}
</button>
/>
</div>
</div>
</div>
</Modal>
)}
{/* Uses the same Modal as edit and reset. It was a hand-rolled copy of
@@ -674,26 +677,19 @@ export default function PosStaffPanel({
deleted, because past bills carry their name.
</p>
<div className="flex flex-wrap items-center gap-2">
<button
<Button
label={deactivateMut.isPending ? 'Deactivating…' : 'Deactivate'}
variant="destructive"
isLoading={deactivateMut.isPending}
isDisabled={deactivateMut.isPending}
onClick={() =>
deactivateMut.mutate(
{ tenantid: tenantId, locationid, userid: confirmOff.user_id },
{ onSettled: () => setConfirmOff(null) },
)
}
disabled={deactivateMut.isPending}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50 whitespace-nowrap"
style={{ background: '#ef4444' }}
>
{deactivateMut.isPending ? 'Deactivating…' : 'Deactivate'}
</button>
<button
onClick={() => setConfirmOff(null)}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer whitespace-nowrap"
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
>
Cancel
</button>
/>
<Button label="Cancel" variant="secondary" onClick={() => setConfirmOff(null)} />
</div>
</Modal>
)}
@@ -702,6 +698,10 @@ export default function PosStaffPanel({
}
const WEEKDAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
/** Full names for the day toggles' accessible names. The single letters are
* ambiguous by design — two T's and two S's — so "T" alone tells a screen
* reader user nothing about which day they just switched on. */
const WEEKDAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
/**
* The shift windows an outlet runs.
@@ -827,20 +827,15 @@ function ShiftManager({
Days
</span>
{WEEKDAY_LABELS.map((d, i) => (
<button
<ToggleButton
key={i}
onClick={() => toggleDay(i)}
className="rounded-full text-[11px] font-extrabold cursor-pointer"
style={{
width: 26,
height: 26,
background: days[i] ? BRAND : '#fff',
color: days[i] ? '#fff' : TEXT_3,
border: `1px solid ${days[i] ? BRAND : BORDER}`,
}}
>
{d}
</button>
label={WEEKDAY_NAMES[i]}
isIconOnly
icon={<span className="text-[11px] font-extrabold">{d}</span>}
size="sm"
isPressed={days[i]}
onPressedChange={() => toggleDay(i)}
/>
))}
</div>
@@ -851,21 +846,13 @@ function ShiftManager({
{error && <p className="text-[12px] mt-2 font-semibold" style={{ color: '#ef4444' }}>{error}</p>}
<div className="flex flex-wrap items-center gap-2 mt-3">
<button
<Button
label={createMut.isPending ? 'Adding…' : 'Add shift'}
isLoading={createMut.isPending}
isDisabled={createMut.isPending}
onClick={submit}
disabled={createMut.isPending}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50"
style={{ background: BRAND }}
>
{createMut.isPending ? 'Adding…' : 'Add shift'}
</button>
<button
onClick={() => { setOpen(false); setError(''); }}
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
>
Cancel
</button>
/>
<Button label="Cancel" variant="secondary" onClick={() => { setOpen(false); setError(''); }} />
</div>
</div>
)}
@@ -893,7 +880,12 @@ function ShiftManager({
: WEEKDAY_LABELS.filter((_, i) => s.weekdays?.[i] === '1').join(' ') || '—'}
</div>
</div>
<button
<Button
label={inactive ? 'Reactivate' : 'Retire'}
aria-label={`${inactive ? 'Reactivate' : 'Retire'} the ${s.name} shift`}
variant="secondary"
size="sm"
isDisabled={updateMut.isPending}
onClick={() =>
updateMut.mutate({
tenantid: tenantId,
@@ -902,16 +894,7 @@ function ShiftManager({
status: inactive ? 'Active' : 'Inactive',
})
}
disabled={updateMut.isPending}
className="rounded-full px-3 py-1 text-[11.5px] font-bold cursor-pointer shrink-0 disabled:opacity-50"
style={
inactive
? { background: tint('#10b981'), color: '#10b981', border: `1px solid ${edge('#10b981')}` }
: { background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }
}
>
{inactive ? 'Reactivate' : 'Retire'}
</button>
/>
</div>
);
})}
@@ -941,15 +924,20 @@ function IconBtn({
onClick: () => void;
children: React.ReactNode;
}) {
// PHASE 6 — on Astryx IconButton. Like Modal below, converting this one
// helper converts every call site (edit / reset password / deactivate) at
// once. `title` becomes `label`: it was the only thing naming these buttons,
// and a title attribute is not an accessible name — three icon-only controls
// per row announced as nothing at all.
return (
<button
<IconButton
label={title}
variant="secondary"
size="sm"
icon={children}
onClick={onClick}
title={title}
className="inline-flex items-center justify-center rounded-full cursor-pointer transition-colors"
style={{ width: 28, height: 28, background: tint(color), color, border: `1px solid ${edge(color)}` }}
>
{children}
</button>
style={{ color }}
/>
);
}

View File

@@ -1,9 +1,20 @@
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { Search, Plus, Minus, Trash2, Receipt, X, Tag, QrCode, CreditCard, PieChart, Barcode, ShoppingCart, User, RefreshCcw, Save, Download, ChevronRight, Zap, Wallet, Smartphone, AlertCircle } from 'lucide-react';
import { Search, Plus, Trash2, Receipt, X, Tag, QrCode, CreditCard, PieChart, Barcode, ShoppingCart, User, RefreshCcw, Save, Download, ChevronRight, Zap, Wallet, Smartphone, AlertCircle } from 'lucide-react';
import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
import { useNotify } from './Toasts';
import { Dialog } from '@astryxdesign/core/Dialog';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { TextInput } from '@astryxdesign/core/TextInput';
import { NumberInput } from '@astryxdesign/core/NumberInput';
import { Badge } from '@astryxdesign/core/Badge';
import { Spinner } from '@astryxdesign/core/Spinner';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table';
/**
* PHASE 6 — the one modal shell for this screen.
@@ -407,6 +418,14 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
.hide-spin-button { -moz-appearance: textfield; }
.pos-scan-glow { box-shadow: 0 0 0 3px rgba(16,185,129,0.15), 0 1px 3px rgba(0,0,0,0.08); }
.pos-scan-glow-red { box-shadow: 0 0 0 3px rgba(239,68,68,0.15), 0 1px 3px rgba(0,0,0,0.08); }
/* The scan field sits inside its own glowing frame, so the input's own
chrome is removed and its type size restored. A cashier aims a
scanner at this from arm's length — the size is the affordance. */
.pos-scan-field input {
font-size: 20px; font-weight: 700; background: transparent;
border: none; box-shadow: none; height: auto; padding-left: 0;
}
.pos-scan-field input::placeholder { font-weight: 400; }
@keyframes scan-pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
.scan-pulse { animation: scan-pulse 1.8s ease-in-out infinite; }
`}</style>
@@ -427,43 +446,34 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
</span>
)}
</div>
{/* PHASE 6 — the till's action bar. Refund mode used to restyle every
button inline (white-on-red); that state now reads from the Refund
button's own variant plus the banner beside the title, so the other
four stop changing colour for a mode they do not control. */}
<div className="flex items-center gap-1.5">
<button
onClick={() => setShowManualSearch(true)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border transition-colors ${isRefundMode ? 'bg-white/10 hover:bg-white/20 text-white border-white/15' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border-slate-200'}`}
>
<Search className="w-3.5 h-3.5" /> Browse
</button>
<button
<Button label="Browse" variant="secondary" size="sm" icon={<Search className="w-3.5 h-3.5" />} onClick={() => setShowManualSearch(true)} />
<Button
label="Refund"
variant={isRefundMode ? 'destructive' : 'secondary'}
size="sm"
icon={<RefreshCcw className="w-3.5 h-3.5" />}
onClick={() => setIsRefundMode(!isRefundMode)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border transition-colors ${isRefundMode ? 'bg-white text-red-600 border-white' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border-slate-200'}`}
>
<RefreshCcw className="w-3.5 h-3.5" /> Refund
</button>
<button
/>
<Button
label="Park"
variant="secondary"
size="sm"
icon={<Save className="w-3.5 h-3.5" />}
isDisabled={cart.length === 0}
onClick={parkCart}
disabled={cart.length === 0}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border transition-colors disabled:opacity-40 ${isRefundMode ? 'bg-white/10 hover:bg-white/20 text-white border-white/15' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border-slate-200'}`}
>
<Save className="w-3.5 h-3.5" /> Park
</button>
<button
onClick={() => setShowParkedPrompt(true)}
className={`relative flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border transition-colors ${isRefundMode ? 'bg-white/10 hover:bg-white/20 text-white border-white/15' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border-slate-200'}`}
>
<Download className="w-3.5 h-3.5" /> Retrieve
{parkedCarts.length > 0 && (
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-amber-400 text-slate-900 rounded-full text-[9px] font-black flex items-center justify-center">
{parkedCarts.length}
</span>
)}
</button>
<button
onClick={() => { checkDailyReset(); setShowReport(true); }}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border transition-colors ${isRefundMode ? 'bg-white/10 hover:bg-white/20 text-white border-white/15' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border-slate-200'}`}
>
<PieChart className="w-3.5 h-3.5" /> Report
</button>
/>
{/* The parked count was a floating dot pinned to the button corner,
outside its label — nothing announced it. */}
<div className="flex items-center gap-1">
<Button label="Retrieve" variant="secondary" size="sm" icon={<Download className="w-3.5 h-3.5" />} onClick={() => setShowParkedPrompt(true)} />
{parkedCarts.length > 0 && <Badge variant="warning" label={String(parkedCarts.length)} />}
</div>
<Button label="Report" variant="secondary" size="sm" icon={<PieChart className="w-3.5 h-3.5" />} onClick={() => { checkDailyReset(); setShowReport(true); }} />
</div>
</div>
@@ -473,21 +483,26 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<div className={`scan-pulse ${isRefundMode ? 'text-red-400' : 'text-emerald-500'}`}>
<Barcode className="w-7 h-7" />
</div>
<input
ref={searchInputRef}
type="text"
placeholder={isRefundMode ? 'Scan item to refund…' : 'Scan barcode or type SKU…'}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
className="flex-1 bg-transparent border-none outline-none font-bold text-xl text-slate-800 placeholder:font-normal placeholder:text-slate-400"
autoFocus
/>
{search && (
<button onClick={() => setSearch('')} className="text-slate-400 hover:text-slate-600">
<X className="w-4 h-4" />
</button>
)}
{/* The scan field keeps its oversized type deliberately — an
operator aims a scanner at this from arm's length and the size
is the affordance, so `pos-scan-field` restores the 20px bold
face that TextInput's `lg` does not reach. What it gains is a
real label (it had none at all) and a clear button that is now
part of the control rather than a bare <button> beside it. */}
<div className="flex-1 pos-scan-field">
<TextInput
ref={searchInputRef}
label={isRefundMode ? 'Scan item to refund' : 'Scan barcode or type SKU'}
isLabelHidden
size="lg"
placeholder={isRefundMode ? 'Scan item to refund…' : 'Scan barcode or type SKU…'}
value={search}
onChange={(v) => setSearch(v)}
onKeyDown={handleSearchKeyDown}
hasClear
hasAutoFocus
/>
</div>
{!search && (
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-widest hidden md:block">
Press Enter
@@ -499,95 +514,78 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
{/* Cart Table */}
<div className="flex-1 overflow-y-auto bg-white">
{stockQ.isLoading ? (
<div className="h-full flex flex-col items-center justify-center gap-3 text-slate-400">
<div className="w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
<p className="text-sm font-semibold">Initializing inventory…</p>
<div className="h-full flex items-center justify-center">
<Spinner size="xl" label="Initializing inventory…" />
</div>
) : cart.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center gap-5 text-slate-300 select-none">
<div className="w-24 h-24 rounded-3xl bg-slate-100 flex items-center justify-center">
<ShoppingCart className="w-12 h-12 text-slate-300" />
</div>
<div className="text-center">
<p className="font-bold text-slate-400 text-lg">Cart is empty</p>
<p className="text-sm text-slate-400 mt-1">Scan a barcode or browse products to add items</p>
</div>
<button
onClick={() => setShowManualSearch(true)}
className="flex items-center gap-2 px-5 py-2.5 bg-slate-900 text-white text-sm font-bold rounded-xl hover:bg-slate-800 transition-colors"
>
<Search className="w-4 h-4" /> Browse Products
</button>
<div className="h-full flex items-center justify-center">
<EmptyState
icon={<ShoppingCart className="w-12 h-12" />}
title="Cart is empty"
description="Scan a barcode or browse products to add items"
actions={<Button label="Browse Products" icon={<Search className="w-4 h-4" />} onClick={() => setShowManualSearch(true)} />}
/>
</div>
) : (
<div className="w-full overflow-x-auto custom-scrollbar">
<table className="w-full text-left border-collapse min-w-[500px]">
<thead className="sticky top-0 z-10 bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-5 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500">Product</th>
<th className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-center">Qty</th>
<th className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-right">Rate</th>
<th className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-right">Disc.</th>
<th className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-right">Amount</th>
<th className="px-5 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-center w-20"></th>
</tr>
</thead>
<tbody>
<Table style={{ minWidth: 500 }}>
<TableHeader>
<TableRow>
<TableHeaderCell>Product</TableHeaderCell>
<TableHeaderCell>Qty</TableHeaderCell>
<TableHeaderCell>Rate</TableHeaderCell>
<TableHeaderCell>Disc.</TableHeaderCell>
<TableHeaderCell>Amount</TableHeaderCell>
<TableHeaderCell><span className="sr-only">Row actions</span></TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{cartWithPromos.map((item, idx) => (
<tr
<TableRow
key={item.id}
className={`group border-b border-slate-100 hover:bg-slate-50/80 transition-colors ${item.isRefund ? 'bg-red-50/50' : ''}`}
style={item.isRefund ? { background: 'rgba(244,63,94,0.06)' } : undefined}
>
<td className="px-5 py-3.5">
<TableCell>
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center shrink-0 mt-0.5">
<span className="text-[10px] font-black text-slate-500">{idx + 1}</span>
</div>
<div>
<div className="flex items-center gap-2">
{item.isRefund && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-600 text-[9px] uppercase font-black rounded tracking-wider">
Refund
</span>
)}
<span className="font-bold text-slate-900 text-[14px] leading-snug">{item.name}</span>
{item.isRefund && <Badge variant="error" label="Refund" />}
<span className="font-bold text-[14px] leading-snug">{item.name}</span>
</div>
<div className="text-[11px] text-slate-400 font-mono mt-0.5">{item.sku}</div>
<div className="text-[11px] font-mono mt-0.5 opacity-50">{item.sku}</div>
</div>
</div>
</td>
<td className="px-4 py-3.5 text-center">
<div className="inline-flex items-center bg-slate-100 rounded-lg overflow-hidden border border-slate-200">
<button
onClick={() => updateCartQty(item.id, item.qty - 1)}
className="w-8 h-8 flex items-center justify-center text-slate-500 hover:bg-red-50 hover:text-red-500 transition-colors"
>
<Minus className="w-3.5 h-3.5" />
</button>
<div className="flex items-center gap-0.5 px-1">
<input
type="number"
step={item.unit === 'kg' ? '0.001' : '1'}
value={item.qty}
onChange={(e) => updateCartQty(item.id, parseFloat(e.target.value) || 0)}
className="w-12 text-center text-sm font-bold text-slate-900 bg-transparent border-none outline-none hide-spin-button"
/>
<span className="text-[10px] font-bold text-slate-400">{item.unit}</span>
</div>
<button
onClick={() => updateCartQty(item.id, item.qty + 1)}
disabled={item.qty >= item.maxQty}
className="w-8 h-8 flex items-center justify-center text-slate-500 hover:bg-emerald-50 hover:text-emerald-600 transition-colors disabled:opacity-30"
>
<Plus className="w-3.5 h-3.5" />
</button>
</TableCell>
<TableCell>
{/* The −/+ pair plus a bare number box is one NumberInput
now: it brings its own steppers, so the two icon-only
buttons (neither of which had an accessible name) are
gone, and `max` finally expresses the stock ceiling that
only the + button used to enforce. */}
<div className="flex items-center gap-1 w-28">
<NumberInput
label={`Quantity for ${item.name}`}
isLabelHidden
size="sm"
value={item.qty}
onChange={(v) => updateCartQty(item.id, v ?? 0)}
step={item.unit === 'kg' ? 0.001 : 1}
min={0}
max={item.maxQty}
/>
<span className="text-[10px] font-bold shrink-0 opacity-50">{item.unit}</span>
</div>
</td>
<td className="px-4 py-3.5 text-right">
<span className="font-bold text-slate-700 text-sm">₹{item.price.toFixed(2)}</span>
<span className="block text-[10px] text-slate-400">/{item.unit}</span>
</td>
<td className="px-4 py-3.5 text-right">
</TableCell>
<TableCell>
<div className="text-right">
<span className="font-bold text-sm">₹{item.price.toFixed(2)}</span>
<span className="block text-[10px] opacity-50">/{item.unit}</span>
</div>
</TableCell>
<TableCell>
{item.discountPerItem > 0 || item.hasBogo ? (
<div className="flex flex-col items-end gap-1">
{item.discountPerItem > 0 && (
@@ -600,60 +598,66 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
-₹{(item.promoDiscount || 0).toFixed(2)}
</span>
)}
<div className="flex gap-1 flex-wrap justify-end">
<div className="flex gap-1 flex-wrap justify-end items-center">
{item.discountPerItem > 0 && (
<span className="flex items-center gap-0.5 text-[9px] font-black text-emerald-700 bg-emerald-100 px-1.5 py-0.5 rounded-md uppercase tracking-wide">
{item.couponCode || 'MANUAL'}
<button onClick={() => removeCoupon(item.id)} className="ml-0.5 hover:text-red-500">
<X className="w-2.5 h-2.5" />
</button>
</span>
)}
{item.hasBogo && (
<span className="text-[9px] font-black text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded-md uppercase tracking-wide">
BOGO
</span>
<>
<Badge variant="success" label={item.couponCode || 'MANUAL'} />
<IconButton
label={`Remove discount from ${item.name}`}
variant="ghost"
size="sm"
icon={<X className="w-3 h-3" />}
onClick={() => removeCoupon(item.id)}
/>
</>
)}
{item.hasBogo && <Badge variant="warning" label="BOGO" />}
</div>
</div>
) : (
<span className="text-slate-300 text-sm">—</span>
<span className="text-sm block text-right opacity-30">—</span>
)}
</td>
<td className={`px-4 py-3.5 text-right font-bold text-[15px] ${item.isRefund ? 'text-red-600' : 'text-slate-900'}`}>
{item.isRefund ? '-' : ''}₹
{(
item.price * item.qty -
item.discountPerItem * item.qty -
(item.promoDiscount || 0)
).toFixed(2)}
</td>
<td className="px-5 py-3.5 text-center">
<div className="flex items-center justify-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
</TableCell>
<TableCell>
<div className={`text-right font-bold text-[15px] ${item.isRefund ? 'text-red-600' : ''}`}>
{item.isRefund ? '-' : ''}₹
{(
item.price * item.qty -
item.discountPerItem * item.qty -
(item.promoDiscount || 0)
).toFixed(2)}
</div>
</TableCell>
<TableCell>
{/* The `opacity-0 group-hover` reveal is gone. On a till
these two are the only way to fix a mis-scan, and they
were invisible until the mouse happened to be over the
row — while still being in the tab order. */}
<div className="flex items-center justify-center gap-1">
<IconButton
label={`Apply discount to ${item.name}`}
variant="secondary"
size="sm"
icon={<Tag className="w-3.5 h-3.5" />}
onClick={() => {
setDiscountPromptItem(item.id);
setCouponCodeInput(item.couponCode || '');
setDiscountAmountInput(item.discountPerItem > 0 ? item.discountPerItem.toString() : '');
}}
className="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-50 text-blue-500 hover:bg-blue-100 transition-colors"
title="Apply Discount"
>
<Tag className="w-3.5 h-3.5" />
</button>
<button
/>
<IconButton
label={`Remove ${item.name} from cart`}
variant="destructive"
size="sm"
icon={<Trash2 className="w-3.5 h-3.5" />}
onClick={() => removeCartItem(item.id)}
className="w-8 h-8 flex items-center justify-center rounded-lg bg-red-50 text-red-500 hover:bg-red-100 transition-colors"
title="Remove"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
/>
</div>
</td>
</tr>
</TableCell>
</TableRow>
))}
</tbody>
</table>
</TableBody>
</Table>
</div>
)}
</div>
@@ -683,21 +687,26 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<div className="flex items-center gap-1.5 justify-end">
<User className="w-3 h-3 text-blue-500" />
<span className="text-xs font-bold text-slate-900">{customer.name || customer.phone}</span>
<button onClick={() => setCustomer(null)} className="text-slate-400 hover:text-slate-600 ml-1">
<X className="w-3 h-3" />
</button>
<IconButton
label="Detach customer from this sale"
variant="ghost"
size="sm"
icon={<X className="w-3 h-3" />}
onClick={() => setCustomer(null)}
/>
</div>
<div className="text-[10px] font-bold text-amber-500 mt-0.5 flex items-center justify-end gap-1">
<Zap className="w-2.5 h-2.5" /> {customer.points} pts
</div>
</div>
) : (
<button
<Button
label="Customer"
variant="secondary"
size="sm"
icon={<Plus className="w-3.5 h-3.5" />}
onClick={() => setShowCustomerPrompt(true)}
className="flex items-center gap-1.5 bg-slate-50 hover:bg-slate-100 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl transition-colors"
>
<Plus className="w-3.5 h-3.5" /> Customer
</button>
/>
)}
</div>
</div>
@@ -761,14 +770,19 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
)}
</div>
<div className="flex gap-2">
<button
{/* PAY keeps its own emerald face and full width: it is the one
control the whole screen exists to reach, and it is hit hundreds of
times a shift. Everything around it is Astryx; this stays a
deliberate exception, marked as one. */}
<div className="flex gap-2 pos-pay-row">
<IconButton
label="Clear cart"
variant="secondary"
size="lg"
icon={<Trash2 className="w-5 h-5" />}
isDisabled={cart.length === 0}
onClick={clearCart}
disabled={cart.length === 0}
className="w-12 h-12 flex items-center justify-center bg-white border border-slate-200 hover:bg-red-50 hover:border-red-200 text-slate-500 hover:text-red-500 rounded-xl transition-all disabled:opacity-40"
>
<Trash2 className="w-5 h-5" />
</button>
/>
<button
onClick={handleCheckoutBtn}
disabled={cart.length === 0 || !customer}
@@ -787,18 +801,18 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<h3 className="font-bold text-slate-900 flex items-center gap-2">
<Search className="w-5 h-5 text-indigo-500" /> Product Search
</h3>
<button onClick={() => setShowManualSearch(false)} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
<X className="w-4 h-4" />
</button>
<IconButton label="Close product search" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setShowManualSearch(false)} />
</div>
<div className="px-5 py-3 border-b border-slate-100">
<input
type="text"
<TextInput
label="Search products"
isLabelHidden
placeholder="Search by name, SKU…"
value={search}
onChange={(e) => setSearch(e.target.value)}
autoFocus
className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm font-medium text-slate-800 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400/20 transition-all"
onChange={(v) => setSearch(v)}
startIcon={<Search className="w-4 h-4" />}
hasClear
hasAutoFocus
/>
</div>
<div className="flex-1 overflow-y-auto">
@@ -810,6 +824,10 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
p.sku.toLowerCase().includes(search.toLowerCase())
)
.map((product) => (
// Stays a native button: it is a full-width result row whose
// whole surface is the hit target, which Button's fixed
// padding and centred label cannot express. It already had a
// disabled state and a text label, so nothing is lost.
<button
key={product.id}
disabled={product.closing <= 0}
@@ -840,52 +858,52 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<div className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center">
<User className="w-6 h-6 text-blue-500" />
</div>
<button onClick={closeCustomerPrompt} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
<X className="w-4 h-4" />
</button>
<IconButton label="Close customer panel" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={closeCustomerPrompt} />
</div>
<h3 className="font-bold text-xl text-slate-900 mb-1">{isNewCustomer ? 'New Customer' : 'Attach Customer'}</h3>
<p className="text-sm text-slate-500 mb-5">{isNewCustomer ? "Please enter the customer's name." : 'Enter mobile number to retrieve loyalty profile.'}</p>
<input
type="tel"
placeholder="e.g. 9876543210"
value={customerPhoneInput}
onChange={(e) => setCustomerPhoneInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !isNewCustomer && checkCustomerPhone()}
disabled={isNewCustomer}
className={`w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-slate-800 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-all ${isNewCustomer ? 'opacity-50 mb-3' : 'mb-4'}`}
autoFocus={!isNewCustomer}
/>
{/* Was `type="tel"` with no filtering, so letters reached a field
the Find button gates on `.length >= 10` — "abcdefghij" opened
a lookup. Astryx has no tel type; digits-only in the handler is
the stricter fix anyway. */}
<div className="mb-4">
<TextInput
label="Customer mobile number"
placeholder="e.g. 9876543210"
value={customerPhoneInput}
onChange={(v) => setCustomerPhoneInput(v.replace(/\D/g, '').slice(0, 10))}
onEnter={() => { if (!isNewCustomer) checkCustomerPhone(); }}
isDisabled={isNewCustomer}
hasAutoFocus={!isNewCustomer}
/>
</div>
{isNewCustomer && (
<input
type="text"
placeholder="Customer Name"
value={customerNameInput}
onChange={(e) => setCustomerNameInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && saveNewCustomer()}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-slate-800 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-all mb-4"
autoFocus
/>
<div className="mb-4">
<TextInput
label="Customer name"
placeholder="Customer Name"
value={customerNameInput}
onChange={(v) => setCustomerNameInput(v)}
onEnter={saveNewCustomer}
hasAutoFocus
/>
</div>
)}
{isNewCustomer ? (
<button
<Button
label="Save & Attach"
isDisabled={!customerNameInput.trim()}
onClick={saveNewCustomer}
disabled={!customerNameInput.trim()}
className="w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-200 disabled:text-slate-400 text-white rounded-xl font-bold transition-colors"
>
Save & Attach
</button>
/>
) : (
<button
<Button
label="Find & Attach"
isDisabled={customerPhoneInput.length < 10}
onClick={checkCustomerPhone}
disabled={customerPhoneInput.length < 10}
className="w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-200 disabled:text-slate-400 text-white rounded-xl font-bold transition-colors"
>
Find & Attach
</button>
/>
)}
</div>
</PosModal>
@@ -898,16 +916,11 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<h3 className="font-bold text-slate-900 flex items-center gap-2">
<Download className="w-5 h-5 text-blue-500" /> Parked Sales
</h3>
<button onClick={() => setShowParkedPrompt(false)} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
<X className="w-4 h-4" />
</button>
<IconButton label="Close parked sales" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setShowParkedPrompt(false)} />
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{parkedCarts.length === 0 ? (
<div className="text-center py-12 text-slate-400">
<Save className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm font-medium">No parked sales</p>
</div>
<EmptyState isCompact icon={<Save className="w-10 h-10" />} title="No parked sales" />
) : (
parkedCarts.map((pc) => (
<div key={pc.id} className="bg-slate-50 border border-slate-200 rounded-xl p-4 flex justify-between items-center hover:border-blue-200 transition-colors">
@@ -917,12 +930,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
{new Date(pc.time).toLocaleTimeString()} · {pc.cart.length} items · {pc.customer ? pc.customer.phone : 'Guest'}
</p>
</div>
<button
onClick={() => retrieveCart(pc)}
className="px-4 py-2 bg-blue-600 text-white text-sm font-bold rounded-lg hover:bg-blue-700 transition-colors"
>
Retrieve
</button>
<Button label="Retrieve" size="sm" onClick={() => retrieveCart(pc)} />
</div>
))
)}
@@ -938,47 +946,28 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<div className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center">
<Tag className="w-6 h-6 text-emerald-600" />
</div>
<button onClick={() => setDiscountPromptItem(null)} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
<X className="w-4 h-4" />
</button>
<IconButton label="Close discount panel" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setDiscountPromptItem(null)} />
</div>
<h3 className="font-bold text-xl text-slate-900 mb-1">Apply Discount</h3>
<p className="text-sm text-slate-500 mb-5">Enter a manual discount or attach a coupon code.</p>
<div className="space-y-3 mb-5">
<div>
<label className="block text-[11px] font-bold text-slate-500 uppercase tracking-wider mb-1.5">
Discount Amount (₹)
</label>
<input
type="number"
placeholder="0.00"
value={discountAmountInput}
onChange={(e) => setDiscountAmountInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyDiscountToItem()}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-slate-800 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 transition-all"
autoFocus
/>
</div>
<div>
<label className="block text-[11px] font-bold text-slate-500 uppercase tracking-wider mb-1.5">
Coupon Code (optional)
</label>
<input
type="text"
placeholder="e.g. OFF10"
value={couponCodeInput}
onChange={(e) => setCouponCodeInput(e.target.value.toUpperCase())}
onKeyDown={(e) => e.key === 'Enter' && applyDiscountToItem()}
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-slate-800 uppercase outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 transition-all"
/>
</div>
<NumberInput
label="Discount Amount (₹)"
value={discountAmountInput === '' ? null : Number(discountAmountInput)}
onChange={(v) => setDiscountAmountInput(v == null ? '' : String(v))}
min={0}
step={0.01}
/>
<TextInput
label="Coupon Code"
isOptional
placeholder="e.g. OFF10"
value={couponCodeInput}
onChange={(v) => setCouponCodeInput(v.toUpperCase())}
onEnter={applyDiscountToItem}
/>
</div>
<button
onClick={applyDiscountToItem}
className="w-full py-3 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold transition-colors"
>
Confirm Discount
</button>
<Button label="Confirm Discount" onClick={applyDiscountToItem} />
</div>
</PosModal>
)}
@@ -994,16 +983,27 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
</p>
<p className="text-4xl font-black tracking-tight text-slate-900">₹{Math.abs(grandTotal).toFixed(2)}</p>
</div>
<button onClick={() => setShowPayment(false)} className="w-8 h-8 flex items-center justify-center rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-500 mt-1">
<X className="w-4 h-4" />
</button>
<IconButton label="Close payment panel" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setShowPayment(false)} />
</div>
<div className="p-6 space-y-5">
{/* Payment Method Tabs */}
<div>
<p className="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2.5">Payment Method</p>
<div className="grid grid-cols-3 gap-2">
{/* SegmentedControl, not three buttons and not SelectableCard.
This is a single-select, and as plain buttons the active
method lived only in border colour. SelectableCard was tried
first and rejected: verified in Chrome, it renders a
focusable div carrying only `aria-label` — no role, no
aria-checked — and it drops a `role`/`aria-checked` you pass
it, so the choice stayed silent. SegmentedControl emits a
real radiogroup with arrow-key navigation. */}
<SegmentedControl
label="Payment method"
layout="fill"
value={payMethod}
onChange={(v) => setPayMethod(v as any)}
>
{(grandTotal >= 0 ? ['cash', 'upi', 'card'] : ['cash', 'store_credit']).map((m) => {
const icons: Record<string, React.ReactNode> = {
cash: <Wallet className="w-4 h-4" />,
@@ -1014,38 +1014,22 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
const labels: Record<string, string> = {
cash: 'Cash', upi: 'UPI', card: 'Card', store_credit: 'Store Credit',
};
return (
<button
key={m}
onClick={() => setPayMethod(m as any)}
className={`flex flex-col items-center gap-1.5 py-3 rounded-xl border-2 font-bold text-sm transition-all ${
payMethod === m
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
: 'border-slate-200 bg-slate-50 text-slate-500 hover:border-slate-300'
}`}
>
{icons[m]}
<span className="text-[11px]">{labels[m]}</span>
</button>
);
return <SegmentedControlItem key={m} value={m} label={labels[m]} icon={icons[m]} />;
})}
</div>
</SegmentedControl>
</div>
{/* Cash Payment */}
{payMethod === 'cash' && grandTotal >= 0 && (
<div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200">
<div>
<label className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2">
Cash Received
</label>
<input
type="number"
value={cashReceived}
onChange={(e) => setCashReceived(e.target.value)}
className="w-full px-4 py-3 bg-slate-50 border-2 border-slate-200 rounded-xl font-black text-2xl text-slate-900 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 transition-all text-right"
placeholder="0.00"
autoFocus
<div className="pos-cash-field">
<NumberInput
label="Cash Received"
value={cashReceived === '' ? null : Number(cashReceived)}
onChange={(v) => setCashReceived(v == null ? '' : String(v))}
min={0}
step={0.01}
hasAutoFocus
/>
</div>
{/* Quick denominations */}
@@ -1056,24 +1040,20 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
.filter((d) => d >= grandTotal * 0.5)
.slice(0, 6)
.map((d) => (
<button
<Button
key={d}
label={`₹${d}`}
size="sm"
variant={parseFloat(cashReceived) === d ? 'primary' : 'secondary'}
onClick={() => setCashReceived(d.toString())}
className={`px-3 py-1.5 rounded-lg text-xs font-bold border transition-colors ${
parseFloat(cashReceived) === d
? 'bg-slate-900 text-white border-slate-900'
: 'bg-slate-50 text-slate-700 border-slate-200 hover:border-slate-400'
}`}
>
₹{d}
</button>
/>
))}
<button
<Button
label="Exact"
size="sm"
variant="secondary"
onClick={() => setCashReceived(Math.ceil(grandTotal).toString())}
className="px-3 py-1.5 rounded-lg text-xs font-bold border bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100"
>
Exact
</button>
/>
</div>
</div>
{cashReceived && parseFloat(cashReceived) >= grandTotal && (
@@ -1105,12 +1085,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<div className="text-center p-5 border-2 border-dashed border-red-200 bg-red-50 rounded-xl">
<AlertCircle className="w-8 h-8 text-red-500 mx-auto mb-2" />
<p className="text-sm font-bold text-red-600 mb-3">Attach a customer to issue Store Credit</p>
<button
<Button
label="Add Customer"
variant="destructive"
size="sm"
onClick={() => { setShowPayment(false); setShowCustomerPrompt(true); }}
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-bold hover:bg-red-700"
>
Add Customer
</button>
/>
</div>
)}
</div>
@@ -1136,6 +1116,8 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
</div>
)}
{/* Same exception as PAY, and for the same reason: this is the
commit point of the transaction. */}
<button
onClick={confirmPayment}
disabled={payMethod === 'store_credit' && !customer}
@@ -1154,9 +1136,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<h3 className="font-bold text-slate-900 flex items-center gap-2">
<Receipt className="w-4 h-4" /> Receipt
</h3>
<button onClick={resetPos} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
<X className="w-4 h-4" />
</button>
<IconButton label="Close receipt" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={resetPos} />
</div>
<div className="p-6 font-mono text-sm text-black print:p-0">
@@ -1205,15 +1185,8 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
</div>
<div className="px-5 py-4 border-t border-slate-100 bg-slate-50 flex gap-3 print:hidden">
<button onClick={resetPos} className="flex-1 py-3 bg-white border border-slate-200 text-slate-700 rounded-xl font-bold hover:bg-slate-50 transition-colors">
New Sale
</button>
<button
onClick={() => window.print()}
className="flex-1 py-3 bg-slate-900 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-slate-800 transition-colors"
>
<Receipt className="w-4 h-4" /> Print
</button>
<Button label="New Sale" variant="secondary" onClick={resetPos} />
<Button label="Print" icon={<Receipt className="w-4 h-4" />} onClick={() => window.print()} />
</div>
</PosModal>
)}
@@ -1225,9 +1198,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
<h3 className="font-bold text-slate-900 flex items-center gap-2">
<PieChart className="w-5 h-5 text-emerald-500" /> Daily Sales Report
</h3>
<button onClick={() => setShowReport(false)} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
<X className="w-4 h-4" />
</button>
<IconButton label="Close daily report" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setShowReport(false)} />
</div>
<div className="p-5 max-h-[70vh] overflow-y-auto space-y-5">

View File

@@ -7,15 +7,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Building2,
Store,
Truck,
CreditCard,
SlidersHorizontal,
Users,
MapPin,
Phone,
Mail,
Plus,
Bike
X
} from 'lucide-react';
import { useFiestaAllTenants, useFiestaTenantLocations, useFiestaUpdateLocation, useFiestaDeleteLocation } from '../services/fiestaQueries';
import { useAppRoles } from '../services/queries';
@@ -26,9 +23,47 @@ import AdminConsole from './AdminConsole';
import { ConfirmModal } from './ConfirmModal';
import type { AuthUser } from '../services/auth';
import { useNotify } from './Toasts';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector';
import { Dialog } from '@astryxdesign/core/Dialog';
import { Badge } from '@astryxdesign/core/Badge';
import { Heading } from '@astryxdesign/core/Heading';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Spinner } from '@astryxdesign/core/Spinner';
type TabKey = 'profile' | 'outlets' | 'users';
/**
* PHASE 6 — the edit-outlet modal's shell.
*
* It was a `fixed inset-0` with a hand-set 450px card and a `✕` character for a
* close button. It did not close on Escape and did not trap focus, so tabbing
* out of the outlet form walked into the store directory behind it.
*/
function SettingsModal({
onClose,
width,
children,
}: {
onClose: () => void;
width: number;
children: React.ReactNode;
}) {
return (
<Dialog
isOpen
onOpenChange={(open) => { if (!open) onClose(); }}
width={width}
maxHeight="90vh"
purpose="info"
>
{children}
</Dialog>
);
}
/** Locally-persisted merchant preferences (survive reload via localStorage). */
interface MerchantSettings {
// Business profile (seeded from live tenant data, then locally editable)
@@ -300,18 +335,13 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
const Icon = t.icon;
const active = activeTab === t.key;
return (
<button
<Button
key={t.key}
label={t.label}
variant={active ? 'primary' : 'ghost'}
icon={<Icon size={16} />}
onClick={() => setActiveTab(t.key)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-bold transition-all duration-200 whitespace-nowrap cursor-pointer border-none ${
active
? 'bg-purple-50 text-[#662582] shadow-sm border-l-2 border-purple-650'
: 'text-slate-600 hover:text-slate-900 hover:bg-white bg-transparent'
}`}
>
<Icon size={16} className={active ? 'text-[#662582]' : 'text-slate-450'} />
<span>{t.label}</span>
</button>
/>
);
})}
</nav>
@@ -388,29 +418,27 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
<div className="space-y-sm">
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">Customer Support & Contacts</span>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-md mt-2">
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px] flex items-center gap-1.5">
<Mail size={12} className="text-slate-400" /> Support Email
</label>
<input
type="email"
value={user?.email || fstr(tenant?.primaryemail) || ''}
readOnly
className="w-full border border-slate-200 rounded-xl py-3 px-4 bg-slate-50/40 outline-none transition-all text-slate-800 font-semibold text-sm shadow-sm"
placeholder="—"
/>
{/* PHASE 6 — both were `readOnly` inputs styled to look like
fields: focusable controls in the tab order that could never
be edited, sitting in a section the comment above already
calls "read-only". They are values, so they read as values.
Every other card in this panel already displays live tenant
data exactly this way. */}
<div className="p-4 bg-white rounded-xl border border-slate-200/60 shadow-sm">
<span className="text-[10px] text-slate-400 uppercase font-black tracking-wider flex items-center gap-1.5">
<Mail size={12} /> Support Email
</span>
<p className="font-bold text-slate-800 text-sm mt-1 break-all">
{user?.email || fstr(tenant?.primaryemail) || '—'}
</p>
</div>
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px] flex items-center gap-1.5">
<Phone size={12} className="text-slate-400" /> Phone Number
</label>
<input
type="text"
value={user?.contactno || fstr(tenant?.primarycontact) || ''}
readOnly
className="w-full border border-slate-200 rounded-xl py-3 px-4 bg-slate-50/40 outline-none transition-all text-slate-800 font-semibold text-sm shadow-sm"
placeholder="—"
/>
<div className="p-4 bg-white rounded-xl border border-slate-200/60 shadow-sm">
<span className="text-[10px] text-slate-400 uppercase font-black tracking-wider flex items-center gap-1.5">
<Phone size={12} /> Phone Number
</span>
<p className="font-bold text-slate-800 text-sm mt-1 font-mono">
{user?.contactno || fstr(tenant?.primarycontact) || '—'}
</p>
</div>
</div>
</div>
@@ -429,13 +457,12 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
</h2>
</div>
<button
<Button
type="button"
label={showStoreOnboarding ? 'View Store Directory' : 'Add Store Branch'}
icon={showStoreOnboarding ? undefined : <Plus size={14} />}
onClick={() => setShowStoreOnboarding(!showStoreOnboarding)}
className="bg-[#662582] hover:bg-purple-800 text-white px-4 py-2.5 rounded-xl text-xs font-bold uppercase tracking-wider flex items-center gap-1.5 cursor-pointer shadow-sm active:scale-95 transition-all border-none"
>
{showStoreOnboarding ? 'View Store Directory' : '+ Add Store Branch'}
</button>
/>
</div>
{showStoreOnboarding ? (
@@ -445,9 +472,14 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
) : (
<>
{locationsQ.isLoading ? (
<div className="text-center py-lg text-slate-400 font-medium text-sm">Loading live outlets…</div>
<div className="py-lg flex justify-center"><Spinner label="Loading live outlets…" /></div>
) : cleanOutlets.length === 0 ? (
<div className="text-center py-lg text-slate-400 font-medium text-sm">No outlets configured yet.</div>
<EmptyState
icon={<Store size={26} />}
title="No outlets configured yet"
description="Add your first store branch to start taking orders."
actions={<Button label="Add Store Branch" icon={<Plus size={14} />} onClick={() => setShowStoreOnboarding(true)} />}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-md max-h-[38rem] overflow-y-auto pr-1 scrollbar-thin">
{cleanOutlets.map((loc, i) => (
@@ -467,31 +499,33 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
</p>
</div>
</div>
{/* Both actions read only "Edit"/"Delete" across a
grid of outlets — the same two words repeated
with no indication which store they act on. */}
<div className="flex items-center justify-end gap-2 flex-wrap shrink-0">
<span className={`shrink-0 px-2.5 py-1 rounded-full text-[9px] font-black uppercase tracking-wider ${
loc.status.toLowerCase() === 'active'
? 'text-emerald-700 bg-emerald-50 border border-emerald-100/50'
: 'text-zinc-555 bg-zinc-100'
}`}>
{loc.status || '—'}
</span>
<button
<Badge
variant={loc.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
label={loc.status || '—'}
/>
<Button
type="button"
label="Edit"
aria-label={`Edit ${loc.locationname}`}
variant="secondary"
size="sm"
onClick={() => {
setEditingStore({ ...loc });
setShowEditStoreModal(true);
}}
className="text-[10px] uppercase font-bold text-purple-600 hover:text-purple-800 hover:bg-purple-50 px-2 py-1 rounded border border-purple-200 transition-colors"
>
Edit
</button>
<button
/>
<Button
type="button"
label="Delete"
aria-label={`Delete ${loc.locationname}`}
variant="destructive"
size="sm"
onClick={() => handleDeleteStore(loc)}
className="text-[10px] uppercase font-bold text-red-600 hover:text-red-800 hover:bg-red-50 px-2 py-1 rounded border border-red-200 transition-colors"
>
Delete
</button>
/>
</div>
</div>
@@ -534,23 +568,24 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
{/* EDIT STORE MODAL */}
{showEditStoreModal && editingStore && (
<div
className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-sm z-[200] flex items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) setShowEditStoreModal(false); }}
>
<div className="bg-white border border-[#e2e8f0] rounded-xl w-[90vw] sm:w-[450px] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" onClick={(e) => e.stopPropagation()}>
<div className="p-4 border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0">
<h4 className="font-bold text-[#0f172a] text-sm flex items-center gap-2">
<Store size={16} className="text-[#662582]" />
Edit Store Outlet Location
</h4>
<button
type="button"
<SettingsModal onClose={() => setShowEditStoreModal(false)} width={450}>
<div className="flex flex-col max-h-[90vh]">
<div className="p-4 flex justify-between items-center shrink-0">
<Heading level={3}>
<span className="flex items-center gap-2">
<Store size={16} className="text-[#662582]" />
Edit Store Outlet Location
</span>
</Heading>
{/* Was a literal "✕" character in a bare button, so it
announced as "multiplication x" and carried no name. */}
<IconButton
label="Close edit outlet"
variant="ghost"
size="sm"
icon={<X size={16} />}
onClick={() => setShowEditStoreModal(false)}
className="text-zinc-400 hover:text-zinc-700 bg-zinc-100 hover:bg-zinc-200 p-1.5 rounded-lg transition-colors cursor-pointer border-none"
>
✕
</button>
/>
</div>
<form onSubmit={handleUpdateStoreSubmit} className="flex flex-col flex-1 min-h-0">
@@ -566,87 +601,67 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
</div>
<div className="grid grid-cols-1 gap-4">
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">OUTLET NAME (*)</label>
<input
type="text"
placeholder="e.g. Coimbatore Main"
value={editingStore.locationname}
onChange={(e) => setEditingStore({ ...editingStore, locationname: e.target.value })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs"
required
/>
</div>
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">LOCAL ZONE AREA (*)</label>
<input
type="text"
placeholder="e.g. RS Puram"
value={editingStore.suburb}
onChange={(e) => setEditingStore({ ...editingStore, suburb: e.target.value })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs"
required
/>
</div>
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">CITY</label>
<input
type="text"
placeholder="e.g. Coimbatore"
value={editingStore.city}
onChange={(e) => setEditingStore({ ...editingStore, city: e.target.value })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs"
/>
</div>
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">CONTACT PHONE (*)</label>
<input
type="text"
placeholder="e.g. 9876543210"
value={editingStore.contactno}
onChange={(e) => setEditingStore({ ...editingStore, contactno: e.target.value })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs"
required
/>
</div>
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">STATUS</label>
<select
value={editingStore.status}
onChange={(e) => setEditingStore({ ...editingStore, status: e.target.value })}
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs"
>
<option value="Active">Active</option>
<option value="Inactive">Inactive</option>
<option value="Suspended">Suspended</option>
</select>
</div>
<TextInput
label="Outlet name" isRequired
placeholder="e.g. Coimbatore Main"
value={editingStore.locationname}
onChange={(v) => setEditingStore({ ...editingStore, locationname: v })}
/>
<TextInput
label="Local zone area" isRequired
placeholder="e.g. RS Puram"
value={editingStore.suburb}
onChange={(v) => setEditingStore({ ...editingStore, suburb: v })}
/>
<TextInput
label="City"
placeholder="e.g. Coimbatore"
value={editingStore.city}
onChange={(v) => setEditingStore({ ...editingStore, city: v })}
/>
{/* The phone field accepted anything at all. Its sibling in
AdminConsole already strips non-digits and caps at 10; this
one edits the same column and did neither. */}
<TextInput
label="Contact phone" isRequired
placeholder="e.g. 9876543210"
value={editingStore.contactno}
onChange={(v) => setEditingStore({ ...editingStore, contactno: v.replace(/\D/g, '').slice(0, 10) })}
startIcon={<Phone size={14} />}
/>
{/* Option values are lower-case because that is what the
column actually holds — verified against gettenantlocations:
every outlet reads "active", not "Active". The old options
were capitalised, so the select never matched the stored
value and showed nothing selected; saving then wrote
whatever it resolved to, which could flip a live store's
status without anyone choosing it. Labels stay capitalised
for display. */}
<Selector
label="Status"
value={String(editingStore.status || '').toLowerCase()}
onChange={(v) => setEditingStore({ ...editingStore, status: v || 'active' })}
options={[
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'suspended', label: 'Suspended' },
]}
/>
</div>
</div>
<div className="p-4 border-t border-[#f1f5f9] flex justify-end gap-3 bg-[#f8fafc] shrink-0">
<button
type="button"
onClick={() => setShowEditStoreModal(false)}
className="px-4 py-2 border border-[#e2e8f0] rounded-lg font-semibold text-zinc-500 hover:bg-zinc-50 cursor-pointer text-xs"
>
Cancel
</button>
<button
<div className="p-4 flex justify-end gap-3 shrink-0">
<Button type="button" label="Cancel" variant="secondary" onClick={() => setShowEditStoreModal(false)} />
<Button
type="submit"
disabled={updateLocationMut.isPending}
className="px-4 py-2 bg-[#662582] hover:bg-purple-800 text-white rounded-lg font-bold shadow-sm cursor-pointer border-none flex items-center gap-2 text-xs"
>
{updateLocationMut.isPending ? 'Updating...' : 'Update Outlet Profile'}
</button>
label={updateLocationMut.isPending ? 'Updating…' : 'Update Outlet Profile'}
isLoading={updateLocationMut.isPending}
isDisabled={updateLocationMut.isPending}
/>
</div>
</form>
</div>
</div>
</SettingsModal>
)}
<ConfirmModal

View File

@@ -28,7 +28,7 @@ import { useFiestaStockStatement, useFiestaCreateStockRequest, useFiestaGetStock
import { num as fnum, productImages, str as fstr, type Row } from '../services/fiestaApi';
import { useStoreCatalogue, isPublishedItem } from '../services/storeCatalogue';
import AwaitingApi from './AwaitingApi';
import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi';
import { SlideDrawer, Card as ShellCard } from './consoleUi';
import { CardCarousel, ProductGallery } from './CatalogueGallery';
import FMCGHoverOverlay from './FMCGHoverOverlay';
import CatalogueDetailPanel from './CatalogueDetailPanel';
@@ -39,6 +39,43 @@ import { TextInput } from '@astryxdesign/core/TextInput';
import { NumberInput } from '@astryxdesign/core/NumberInput';
import { DateInput } from '@astryxdesign/core/DateInput';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Badge } from '@astryxdesign/core/Badge';
import { Dialog } from '@astryxdesign/core/Dialog';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import {
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
} from '@astryxdesign/core/Table';
/**
* PHASE 6 — the quantity modal's shell.
*
* It was a `fixed inset-0` with its own backdrop div and a hand-set 340px card.
* It did not close on Escape and did not trap focus, so tabbing out of the
* quantity field walked into the product grid behind it.
*/
function CatalogueModal({
onClose,
width,
children,
}: {
onClose: () => void;
width: number;
children: React.ReactNode;
}) {
return (
<Dialog
isOpen
onOpenChange={(open) => { if (!open) onClose(); }}
width={width}
maxHeight="90vh"
purpose="info"
>
{children}
</Dialog>
);
}
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
@@ -374,49 +411,44 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
{/* Tabs and Controls */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 w-full">
<div className="flex items-center gap-1 bg-zinc-100/80 p-1 rounded-xl border border-zinc-200/60 w-full sm:w-auto sm:inline-flex">
<button
onClick={() => setView('catalogue')}
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
view === 'catalogue' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
}`}
{/* PHASE 6 — the three view buttons were a hand-built segmented control:
they looked like one but announced as three unrelated buttons with no
indication which view was active. SegmentedControl is the real thing,
with a radiogroup and arrow-key navigation. */}
<div className="w-full sm:w-auto">
<SegmentedControl
label="Catalogue view"
value={view}
onChange={(v) => setView(v as typeof view)}
>
<Boxes size={14} /> Browse Catalogue ({products.length})
</button>
<button
onClick={() => setView('inventory')}
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
view === 'inventory' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
}`}
>
<Store size={14} /> My Store Inventory ({inventory.length})
</button>
<button
onClick={() => setView('requests')}
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
view === 'requests' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
}`}
>
<Inbox size={14} /> My Requests ({Object.keys(picks).length})
</button>
<SegmentedControlItem value="catalogue" label={`Browse Catalogue (${products.length})`} icon={<Boxes size={14} />} />
<SegmentedControlItem value="inventory" label={`My Store Inventory (${inventory.length})`} icon={<Store size={14} />} />
<SegmentedControlItem value="requests" label={`My Requests (${Object.keys(picks).length})`} icon={<Inbox size={14} />} />
</SegmentedControl>
</div>
{/* Counter sales. Deliberately outside the tab group and always visible:
it is an action, not another way of looking at the catalogue, and the
store needs it from wherever they are on this page. No outlet picker
is passed — a store user can only ever import against their own. */}
<button
onClick={() => setShowOfflineSales(true)}
disabled={!storeLocationId}
{/* The `title` moves to the wrapper. A disabled control emits no pointer
events, so on the button itself the tooltip explaining *why* it was
disabled never appeared — on the one state that needed it. Button has
no `disabledMessage` (that is a Selector/RadioList prop). */}
<span
title={
storeLocationId
? 'Upload an Excel sheet of counter sales to reduce your stock'
: 'Your store outlet is still loading'
}
className="flex items-center justify-center gap-1.5 shrink-0 px-3 py-2 rounded-xl text-xs font-bold text-white bg-[#662582] hover:bg-[#531e6a] transition shadow-sm cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed w-full sm:w-auto"
>
<UploadCloud size={14} /> Upload Offline Sales
</button>
<Button
label="Upload Offline Sales"
icon={<UploadCloud size={14} />}
isDisabled={!storeLocationId}
onClick={() => setShowOfflineSales(true)}
/>
</span>
{/* PHASE 6 — DateInput carries its own label, so the separate "Date:"
caption is dropped rather than duplicated beside it. The comment sits
@@ -486,12 +518,14 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
)}
{(selectedCategories.length > 0 || stockHealthFilter.length > 0 || search) && (
<button
onClick={() => { setSelectedCategories([]); setStockHealthFilter([]); setSearch(''); }}
className="mt-2 w-full py-2 bg-slate-100 text-slate-600 rounded-xl text-[10px] font-bold uppercase tracking-wider hover:bg-slate-200 transition-colors cursor-pointer"
>
Clear All Filters
</button>
<div className="mt-2">
<Button
label="Clear all filters"
variant="secondary"
size="sm"
onClick={() => { setSelectedCategories([]); setStockHealthFilter([]); setSearch(''); }}
/>
</div>
)}
</div>
</div>
@@ -516,12 +550,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
</div>
{/* Action Toolbar */}
<div className="flex items-center gap-3">
<button
<Button
label="Filter"
variant="secondary"
size="sm"
icon={<Layers size={14} />}
onClick={() => setIsLocalSidebarOpen(!isLocalSidebarOpen)}
className="flex items-center gap-2 bg-white border border-slate-200 px-3 py-2 rounded-xl text-xs font-bold hover:bg-slate-50 transition-colors text-slate-700 shadow-sm cursor-pointer h-[36px]"
>
<Layers size={14} /> Filter
</button>
/>
{/* `hasClear` replaces the absolutely-positioned X button, and
`startIcon` replaces the absolutely-positioned magnifier — so
@@ -600,25 +635,39 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />}
{picks[p.id].status === 'Pending' ? 'Requested' : picks[p.id].status} ({picks[p.id].qty})
</span>
{/* Both carried only a `title` and, for the second,
a bare X — so a card grid announced "Edit" and
nothing, N times, with no product name. */}
<div className="flex gap-1">
{picks[p.id].status === 'Pending' && (
<button onClick={(e) => { e.stopPropagation(); setTempQty(picks[p.id].qty); setActiveQtyProduct(p); }} title="Edit Quantity" className="px-2 py-0.5 text-amber-600 hover:bg-amber-100 bg-white border border-amber-200 transition-colors shadow-sm text-[9px] font-bold">Edit</button>
<Button
label="Edit"
aria-label={`Edit requested quantity for ${p.name}`}
variant="secondary"
size="sm"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setTempQty(picks[p.id].qty); setActiveQtyProduct(p); }}
/>
)}
{picks[p.id].status !== 'Approved' && (
<button onClick={(e) => { e.stopPropagation(); togglePick(p.id); }} title="Remove" className={`px-1.5 py-0.5 bg-white border transition-colors shadow-sm ${
picks[p.id].status === 'Rejected' ? 'text-rose-500 border-rose-200' :
'text-amber-500 border-amber-200'
}`}><X size={12} /></button>
<IconButton
label={`Remove ${p.name} from your request`}
variant="secondary"
size="sm"
icon={<X size={12} />}
onClick={(e: React.MouseEvent) => { e.stopPropagation(); togglePick(p.id); }}
/>
)}
</div>
</div>
) : (
<button
onClick={(e) => { e.stopPropagation(); setTempQty(1); setActiveQtyProduct(p); }}
className="w-full bg-[#f8fafc] hover:bg-[#662582] text-slate-600 hover:text-white border border-slate-200 hover:border-[#662582] px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
>
<Plus size={12} /> Add to Store
</button>
<Button
label="Add to Store"
aria-label={`Add ${p.name} to your store`}
variant="secondary"
size="sm"
icon={<Plus size={12} />}
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setTempQty(1); setActiveQtyProduct(p); }}
/>
)}
</div>
</div>
@@ -639,105 +688,93 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
sub="Browse the catalogue and add items to your store to request stock."
/>
) : (
<div className="bg-white rounded-2xl border border-slate-200 shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] overflow-hidden">
<ShellCard className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse" style={{ minWidth: 800 }}>
<thead>
<tr className="bg-slate-50/80 border-b border-slate-200">
<Table style={{ minWidth: 800 }}>
<TableHeader>
<TableRow>
{['Requested At', 'Store', 'Product', 'Qty', 'Status', 'Resolved At', ''].map((h, i) => (
<th key={i} className="px-5 py-4 text-[10px] font-black uppercase tracking-widest text-slate-500 whitespace-nowrap">{h}</th>
<TableHeaderCell key={i}>{h}</TableHeaderCell>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
</TableRow>
</TableHeader>
<TableBody>
{(stockRequestsQ.data || []).map((data: any) => {
const pid = String(data.productid);
const prod = products.find(p => String(p.id) === pid);
const isApproved = data.status === 'Approved';
const isRejected = data.status === 'Rejected';
const isCancelled = data.status === 'Cancelled';
const isReceived = data.status === 'Received';
// Same mapping as the admin side of this flow: 'Received'
// was indigo and 'Cancelled' slate, neither of which has a
// Badge equivalent, so they take the nearest semantic
// meaning rather than an invented hue.
const statusVariant =
data.status === 'Received' ? 'info' :
data.status === 'Approved' ? 'success' :
data.status === 'Rejected' ? 'error' :
data.status === 'Cancelled' ? 'neutral' :
'warning';
return (
<tr key={data.requestid} className="transition-all duration-200 hover:bg-slate-50/80 group">
<td className="px-5 py-4 whitespace-nowrap">
<div className="flex flex-col">
<span className="text-sm font-semibold text-slate-900">
<TableRow key={data.requestid} className="group">
<TableCell>
<div className="flex flex-col whitespace-nowrap">
<span className="text-sm font-semibold">
{data.created ? new Date(data.created).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'}
</span>
<span className="text-xs font-medium text-slate-500">
<span className="text-xs font-medium opacity-60">
{data.created ? new Date(data.created).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
</td>
<td className="px-5 py-4 whitespace-nowrap">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-indigo-50/50 text-indigo-700 border border-indigo-100/50">
<Store size={14} className="text-indigo-500" />
</TableCell>
<TableCell>
<span className="inline-flex items-center gap-2 whitespace-nowrap">
<Store size={14} className="text-indigo-500" />
<span className="font-bold text-xs">{storeName}</span>
</div>
</td>
<td className="px-5 py-4">
</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<>
<div className="relative w-10 h-10 rounded-xl overflow-hidden border border-slate-200 bg-white shadow-sm shrink-0 group-hover:shadow transition-shadow">
<img src={data.productimage || prod?.image || PLACEHOLDER} alt={data.productname || prod?.name || 'Product'} className="w-full h-full object-cover" />
</div>
<div className="min-w-0 flex-1">
<p className="font-bold text-sm text-slate-900 truncate group-hover:text-indigo-700 transition-colors">{data.productname || prod?.name}</p>
<p className="text-xs font-medium text-slate-500 truncate mt-0.5">{prod?.sku || `SKU-${data.productid}`}</p>
</div>
</>
<div className="relative w-10 h-10 rounded-xl overflow-hidden shrink-0">
<img src={data.productimage || prod?.image || PLACEHOLDER} alt="" className="w-full h-full object-cover" />
</div>
<div className="min-w-0 flex-1">
<p className="font-bold text-sm truncate">{data.productname || prod?.name}</p>
<p className="text-xs font-medium truncate mt-0.5 opacity-60">{prod?.sku || `SKU-${data.productid}`}</p>
</div>
</div>
</td>
<td className="px-5 py-4 whitespace-nowrap">
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 rounded-md bg-slate-100 text-slate-700 font-bold text-sm border border-slate-200/60">
{data.qty || '—'}
</span>
</td>
<td className="px-5 py-4 whitespace-nowrap">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${
isReceived ? 'bg-indigo-50 text-indigo-700 border-indigo-200' :
isApproved ? 'bg-emerald-50 text-emerald-700 border-emerald-200' :
isRejected ? 'bg-rose-50 text-rose-700 border-rose-200' :
isCancelled ? 'bg-slate-50 text-slate-700 border-slate-200' :
'bg-amber-50 text-amber-700 border-amber-200'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${
isReceived ? 'bg-indigo-500' :
isApproved ? 'bg-emerald-500' :
isRejected ? 'bg-rose-500' :
isCancelled ? 'bg-slate-400' :
'bg-amber-500 animate-pulse'
}`} />
{data.status || '—'}
</span>
</td>
<td className="px-5 py-4 whitespace-nowrap">
<span className="text-xs font-medium text-slate-500">
</TableCell>
<TableCell>
<span className="font-bold text-sm tabular-nums">{data.qty || '—'}</span>
</TableCell>
<TableCell>
<Badge variant={statusVariant} label={data.status || '—'} />
</TableCell>
<TableCell>
<span className="text-xs font-medium whitespace-nowrap opacity-60">
{data.updated ? new Date(data.updated).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'}
</span>
</td>
<td className="px-5 py-4 whitespace-nowrap text-right">
</TableCell>
<TableCell>
{isApproved && (
<button
onClick={(e) => {
e.stopPropagation();
handleMarkReceived(data);
}}
disabled={updateRequestMutation.isPending}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold text-white bg-indigo-600 hover:bg-indigo-700 transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<CheckCircle2 size={14} /> Mark as Received
</button>
<div className="text-right">
<Button
label="Mark as Received"
aria-label={`Mark ${data.productname || prod?.name || 'product'} as received`}
size="sm"
icon={<CheckCircle2 size={14} />}
isDisabled={updateRequestMutation.isPending}
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleMarkReceived(data); }}
/>
</div>
)}
</td>
</tr>
</TableCell>
</TableRow>
);
})}
</tbody>
</table>
</TableBody>
</Table>
</div>
</div>
</ShellCard>
)}
</div>
)}
@@ -756,9 +793,11 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
title="No stock matches your filters"
sub="Try a different keyword or clear the sidebar filters to find an item in your store."
action={
<button onClick={() => { setSearch(''); setSelectedCategories([]); setStockHealthFilter([]); }} className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#662582] hover:bg-[#531e6a] transition shadow-sm cursor-pointer">
<X size={13} /> Clear filters
</button>
<Button
label="Clear filters"
icon={<X size={13} />}
onClick={() => { setSearch(''); setSelectedCategories([]); setStockHealthFilter([]); }}
/>
}
/>
) : (
@@ -794,11 +833,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
<span>Stock: <strong className="font-extrabold font-mono text-slate-900">{(it.closing ?? 0).toLocaleString('en-IN')}</strong></span>
</div>
<button
className="w-full bg-[#f8fafc] group-hover:bg-[#662582] text-slate-600 group-hover:text-white border border-slate-200 group-hover:border-[#662582] px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
>
{/* Was a <button> with no onClick — a control that did
nothing but sat in the tab order and told a screen
reader it was actionable. It is a stock readout, so
it reads as one. */}
<div className="w-full px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-center" style={{ background: 'rgba(127,127,127,0.06)' }}>
Stock Level: {(it.closing ?? 0).toLocaleString('en-IN')}
</button>
</div>
</div>
</div>
</div>
@@ -815,7 +856,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
<div className="bg-[#0f172a] text-white rounded-2xl shadow-2xl border border-white/10 px-5 py-4 w-80 animate-in slide-in-from-bottom-4 duration-300">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold">{pickCount} product{pickCount > 1 ? 's' : ''} requested</span>
<button onClick={() => { setPicks({}); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer transition-colors">Clear All</button>
<Button label="Clear All" variant="ghost" size="sm" onClick={() => { setPicks({}); setNotice(false); }} />
</div>
<AwaitingApi label="Request submitted to Admin" api="stock-request API" compact className="bg-white/5 border-white/15 text-emerald-300" />
</div>
@@ -825,12 +866,8 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
{/* QUANTITY SELECTION CENTERED MODAL */}
{activeQtyProduct && (
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-slate-900/60 backdrop-blur-md animate-in fade-in duration-300"
onClick={() => setActiveQtyProduct(null)}
/>
<div className="relative w-[340px] max-w-[95vw] bg-white rounded-2xl shadow-[0_24px_60px_rgba(0,0,0,0.15)] border border-white/50 p-5 animate-in zoom-in-[0.97] duration-300 flex flex-col gap-4">
<CatalogueModal onClose={() => setActiveQtyProduct(null)} width={340}>
<div className="p-5 flex flex-col gap-4">
{/* Header */}
<div className="flex justify-between items-center pb-2 border-b border-slate-100/80">
@@ -838,12 +875,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
<h2 className="text-base font-bold text-slate-900 tracking-tight">Request Stock</h2>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">Bulk Order</p>
</div>
<button
<IconButton
label="Close request stock"
variant="ghost"
size="sm"
icon={<X size={14} strokeWidth={2.5} />}
onClick={() => setActiveQtyProduct(null)}
className="w-7 h-7 flex items-center justify-center rounded-full bg-slate-50 hover:bg-slate-100 text-slate-400 hover:text-slate-700 transition-colors"
>
<X size={14} strokeWidth={2.5} />
</button>
/>
</div>
{/* Product Card summary */}
@@ -868,17 +906,14 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
<label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 block mb-2">Quick Select</label>
<div className="grid grid-cols-4 gap-2">
{[10, 25, 50, 100].map(qty => (
<button
<Button
key={qty}
label={String(qty)}
aria-label={`Request ${qty} units`}
size="sm"
variant={tempQty === qty ? 'primary' : 'secondary'}
onClick={() => setTempQty(qty)}
className={`relative overflow-hidden py-1.5 rounded-lg text-[11px] font-bold transition-all duration-300 ${
tempQty === qty
? 'bg-[#662582] text-white shadow-sm ring-1 ring-[#662582] ring-offset-1'
: 'bg-white text-slate-600 border border-slate-200 hover:border-[#662582]/40 hover:bg-purple-50/50'
}`}
>
{qty}
</button>
/>
))}
</div>
</div>
@@ -903,21 +938,20 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
</span>
</div>
<button
<Button
label="Confirm Request"
icon={<CheckCircle2 size={14} strokeWidth={2.5} />}
onClick={() => {
setPickQty(activeQtyProduct.id, tempQty);
setActiveQtyProduct(null);
setNotice(true);
setTimeout(() => setNotice(false), 3000);
}}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl text-xs font-bold transition-all bg-gradient-to-r from-[#662582] to-purple-800 text-white shadow-[0_4px_12px_rgba(102,37,130,0.2)] hover:shadow-[0_6px_16px_rgba(102,37,130,0.3)] hover:-translate-y-0.5"
>
<CheckCircle2 size={14} strokeWidth={2.5} /> Confirm Request
</button>
/>
</div>
</div>
</div>
</CatalogueModal>
)}
{/* Floating FMCG Details Panel */}
@@ -997,16 +1031,15 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
<CheckCircle2 size={18} className="text-emerald-600" />
<span className="text-sm font-semibold text-emerald-800">Approved <span className="font-bold">({pick.qty} units)</span> — confirm once it arrives</span>
</div>
<button
<Button
label="Mark as Received"
icon={<CheckCircle2 size={18} />}
isDisabled={updateRequestMutation.isPending}
onClick={() => {
setSelectedProduct(null);
handleMarkReceived({ productid: selectedProduct.id, qty: pick.qty, requestid: pick.requestid });
}}
disabled={updateRequestMutation.isPending}
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl bg-indigo-600 text-white hover:bg-indigo-700 font-semibold text-sm transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<CheckCircle2 size={18} /> Mark as Received
</button>
/>
</div>
) : isRejected ? (
<div className="flex flex-col gap-3">
@@ -1014,9 +1047,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
<X size={18} className="text-rose-600" />
<span className="text-sm font-semibold text-rose-800">Stock request was rejected</span>
</div>
<button onClick={() => { setSelectedProduct(null); togglePick(selectedProduct.id); }} className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl bg-white text-slate-700 hover:bg-slate-50 shadow-sm border border-slate-200 font-semibold text-sm transition-colors">
Clear Request
</button>
<Button label="Clear Request" variant="secondary" onClick={() => { setSelectedProduct(null); togglePick(selectedProduct.id); }} />
</div>
) : isPending ? (
<div className="flex flex-col gap-3">
@@ -1025,18 +1056,12 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
<span className="text-sm font-semibold text-amber-800">Requested <span className="font-bold">({pick.qty} units)</span></span>
</div>
<button onClick={() => { setSelectedProduct(null); setTempQty(pick.qty); setActiveQtyProduct(selectedProduct); }} className="px-4 py-2 rounded-lg text-amber-700 bg-white hover:bg-amber-100 border border-amber-200 text-sm font-semibold transition-colors shadow-sm">
Edit
</button>
<Button label="Edit" variant="secondary" size="sm" onClick={() => { setSelectedProduct(null); setTempQty(pick.qty); setActiveQtyProduct(selectedProduct); }} />
</div>
<button onClick={() => { setSelectedProduct(null); togglePick(selectedProduct.id); }} className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl bg-white text-rose-600 hover:bg-rose-50 border border-slate-200 font-semibold text-sm transition-colors">
<X size={18} strokeWidth={2} /> Cancel Request
</button>
<Button label="Cancel Request" variant="destructive" icon={<X size={18} strokeWidth={2} />} onClick={() => { setSelectedProduct(null); togglePick(selectedProduct.id); }} />
</div>
) : (
<button onClick={() => { setSelectedProduct(null); setTempQty(1); setActiveQtyProduct(selectedProduct); }} className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-semibold transition-colors bg-[#662582] text-white hover:bg-[#531e6a]">
<Plus size={18} strokeWidth={2} /> Request Stock
</button>
<Button label="Request Stock" icon={<Plus size={18} strokeWidth={2} />} onClick={() => { setSelectedProduct(null); setTempQty(1); setActiveQtyProduct(selectedProduct); }} />
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,47 @@ import { useAppRoles } from '../services/queries';
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
import { useNotify } from './Toasts';
import { Card as ShellCard } from './consoleUi';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector';
import { RadioList, RadioListItem } from '@astryxdesign/core/RadioList';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { Dialog } from '@astryxdesign/core/Dialog';
import { Heading } from '@astryxdesign/core/Heading';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Spinner } from '@astryxdesign/core/Spinner';
/**
* PHASE 6 — the shared modal shell for this panel.
*
* Both overlays here were the same hand-rolled `fixed inset-0` construction:
* a blurred backdrop, a white card, an inline max-width, and a header X.
* Neither closed on Escape and neither trapped focus, so tabbing out of the
* create form walked straight into the directory behind it.
*/
function UsersModal({
onClose,
width,
children,
}: {
onClose: () => void;
width: number;
children: React.ReactNode;
}) {
return (
<Dialog
isOpen
onOpenChange={(open) => { if (!open) onClose(); }}
width={width}
maxHeight="90vh"
purpose="info"
>
{children}
</Dialog>
);
}
interface UsersPanelProps {
tenantId?: number;
@@ -326,72 +367,66 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</div>
</div>
<button
onClick={() => setShowAddUserModal(true)}
className="bg-purple-650 hover:bg-purple-755 text-white px-5 py-3 rounded-xl text-sm font-bold uppercase tracking-wider flex items-center justify-center gap-2 cursor-pointer shadow-sm active:scale-95 transition-all border-none"
>
<Plus size={16} /> Add Team Member
</button>
<Button label="Add Team Member" icon={<Plus size={16} />} onClick={() => setShowAddUserModal(true)} />
</div>
{/* Search & Filter Utility Bar */}
<div className="flex flex-col md:flex-row gap-6 items-stretch md:items-center justify-between mb-4">
<div className="relative w-full md:max-w-sm shrink-0">
<div className="relative flex w-full items-center border-b-2 border-slate-200 focus-within:border-purple-600 transition-colors duration-300">
<span className="pl-1 flex items-center pointer-events-none text-slate-400">
<Search size={16} strokeWidth={3} />
</span>
<input
type="text"
placeholder="Search team members..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-3 pr-10 py-2.5 bg-transparent border-none text-sm font-semibold text-slate-800 placeholder-slate-400 focus:outline-none outline-none"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-700 active:scale-95 transition-all p-1.5 flex items-center justify-center"
>
<X size={14} strokeWidth={3} />
</button>
)}
</div>
{/* Measured in Chrome: this collapsed to 8px after the swap. The old
`<input>` carried its own intrinsic width; TextInput does not, so the
scrollable filter row beside it claimed the whole line. A fixed width
here plus `min-w-0` on that row is what actually holds it open —
flex-basis alone did not. */}
<div className="w-full md:w-96 shrink-0">
<TextInput
label="Search team members"
isLabelHidden
placeholder="Search team members..."
value={search}
onChange={(v) => setSearch(v)}
startIcon={<Search size={16} />}
hasClear
/>
</div>
{/* Role filter capsules */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 md:pb-0 scrollbar-none select-none">
<button
onClick={() => setUserRoleFilter('ALL')}
className={`px-3.5 py-2 rounded-xl text-xs uppercase tracking-wider font-extrabold transition-all duration-200 cursor-pointer border ${
userRoleFilter === 'ALL'
? 'bg-purple-600 text-white border-purple-600 shadow-sm'
: 'bg-white text-slate-600 border-slate-200/80 hover:text-slate-800 hover:bg-slate-100'
}`}
{/* Role filter — a single-select, so a real one. As a row of buttons
nothing announced which filter was active; SegmentedControl emits a
radiogroup with arrow-key navigation. */}
<div className="min-w-0 overflow-x-auto pb-1 md:pb-0 scrollbar-none select-none">
<SegmentedControl
label="Filter by role"
size="sm"
value={userRoleFilter}
onChange={(v) => setUserRoleFilter(v)}
>
All Roles
</button>
{roleOptions.map((roleNameStr) => (
<button
key={roleNameStr}
onClick={() => setUserRoleFilter(roleNameStr)}
className={`px-3.5 py-2 rounded-xl text-xs uppercase tracking-wider font-extrabold transition-all duration-200 cursor-pointer border whitespace-nowrap ${
userRoleFilter === roleNameStr
? 'bg-purple-600 text-white border-purple-600 shadow-sm'
: 'bg-white text-slate-600 border-slate-200/80 hover:text-slate-800 hover:bg-slate-100'
}`}
>
{roleNameStr}
</button>
))}
<SegmentedControlItem value="ALL" label="All Roles" />
{roleOptions.map((roleNameStr) => (
<SegmentedControlItem key={roleNameStr} value={roleNameStr} label={roleNameStr} />
))}
</SegmentedControl>
</div>
</div>
{/* Directory Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredUsers.length === 0 ? (
<div className="col-span-full bg-white border border-slate-200/60 rounded-2xl p-12 text-center text-slate-500 font-bold text-base">
{usersQ.isLoading ? 'Loading team list…' : 'No matches found in team list.'}
<div className="col-span-full">
<ShellCard className="p-12">
{usersQ.isLoading ? (
<div className="flex justify-center"><Spinner label="Loading team list…" /></div>
) : (
<EmptyState
icon={<Users size={26} />}
title="No matches found in team list"
description={search || userRoleFilter !== 'ALL' ? 'Try a different search or role filter.' : 'Add your first team member to get started.'}
actions={
search || userRoleFilter !== 'ALL'
? <Button label="Clear filters" variant="secondary" onClick={() => { setSearch(''); setUserRoleFilter('ALL'); }} />
: <Button label="Add Team Member" icon={<Plus size={14} />} onClick={() => setShowAddUserModal(true)} />
}
/>
)}
</ShellCard>
</div>
) : (
filteredUsers.map((u) => {
@@ -453,17 +488,17 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
{roleInfo.label}
</span>
{u.roleid !== 1 && (
<button
// The label alone read "Assign"/"Unassign" on every card,
// so a screen reader heard the same two words N times with
// no idea whose account they acted on.
<Button
label={u.roleid <= 0 ? 'Assign' : 'Unassign'}
aria-label={`${u.roleid <= 0 ? 'Assign' : 'Unassign'} ${u.name}`}
variant="secondary"
size="sm"
isDisabled={updateUserMut.isPending}
onClick={() => handleToggleAssign(u.userid, u.roleid)}
disabled={updateUserMut.isPending}
className={`px-2 py-0.5 rounded-[8px] text-[9px] font-extrabold uppercase transition-colors cursor-pointer border ${
u.roleid <= 0
? 'bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100'
: 'bg-slate-50 text-slate-500 border-slate-200 hover:bg-slate-100 hover:text-slate-700'
} ${updateUserMut.isPending ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{u.roleid <= 0 ? 'Assign' : 'Unassign'}
</button>
/>
)}
</div>
@@ -477,26 +512,26 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
{/* CREATE NEW USER MODAL */}
{showAddUserModal && (
<div
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md select-none"
onClick={(e) => { if (e.target === e.currentTarget) setShowAddUserModal(false); }}
>
<div className="bg-white border border-slate-200/80 w-full max-w-[30rem] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-sm font-sans cursor-default">
<UsersModal onClose={() => setShowAddUserModal(false)} width={480}>
<div className="flex flex-col max-h-[90vh] text-sm font-sans">
{/* Modal Header */}
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center shrink-0">
<h4 className="font-bold text-slate-900 flex items-center gap-2.5 text-base">
<div className="w-8 h-8 rounded-lg bg-purple-50 text-purple-650 flex items-center justify-center">
<Users size={16} />
</div>
Add Team Member
</h4>
<button
<div className="p-5 flex justify-between items-center shrink-0">
<Heading level={3}>
<span className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-purple-50 text-purple-650 flex items-center justify-center">
<Users size={16} />
</div>
Add Team Member
</span>
</Heading>
<IconButton
label="Close add team member"
variant="ghost"
size="sm"
icon={<X size={18} />}
onClick={() => setShowAddUserModal(false)}
className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 cursor-pointer transition-colors"
>
<X size={18} />
</button>
/>
</div>
{/* Modal Form */}
@@ -507,147 +542,93 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</p>
<div className="space-y-4">
{/* Firstname */}
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">FIRSTNAME (*)</label>
<input
type="text"
placeholder="e.g. Harini"
value={newUser.firstname}
onChange={(e) => setNewUser({ ...newUser, firstname: e.target.value })}
className="w-full border border-slate-200 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
required
/>
</div>
<TextInput
label="First name" isRequired
placeholder="e.g. Harini"
value={newUser.firstname}
onChange={(v) => setNewUser({ ...newUser, firstname: v })}
/>
{/* Contactno */}
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">CONTACTNO (*)</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Phone size={14} />
</span>
<input
type="text"
inputMode="numeric"
maxLength={10}
placeholder="e.g. 9988776655"
value={newUser.contactno}
onChange={(e) => setNewUser({ ...newUser, contactno: e.target.value.replace(/\D/g, '') })}
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
required
/>
</div>
</div>
<TextInput
label="Contact number" isRequired
placeholder="e.g. 9988776655"
value={newUser.contactno}
onChange={(v) => setNewUser({ ...newUser, contactno: v.replace(/\D/g, '').slice(0, 10) })}
startIcon={<Phone size={14} />}
/>
{/* Email */}
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">EMAIL (*)</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Mail size={14} />
</span>
<input
type="email"
placeholder="e.g. harini@store.com"
value={newUser.email}
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
required
/>
</div>
</div>
<TextInput
label="Email" type="email" isRequired
placeholder="e.g. harini@store.com"
value={newUser.email}
onChange={(v) => setNewUser({ ...newUser, email: v })}
startIcon={<Mail size={14} />}
/>
{/* Interactive Role Buttons instead of standard select */}
<div className="space-y-2">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">SELECT ACCOUNT ROLE (*)</label>
<div className="grid grid-cols-2 gap-2.5">
{roleChoices.map((r) => {
const isSelected = newUser.roleid === r.id;
const Icon = r.icon;
return (
<button
key={r.id}
type="button"
onClick={() => setNewUser({ ...newUser, roleid: r.id })}
className={`p-3 rounded-xl border text-left transition-all cursor-pointer flex gap-2.5 items-start ${
isSelected
? 'bg-purple-50 border-purple-500 ring-2 ring-purple-500/10'
: 'bg-slate-50/40 hover:bg-slate-50 border-slate-200/80'
}`}
>
<Icon size={14} className={`shrink-0 mt-0.5 ${isSelected ? 'text-purple-650' : 'text-slate-450'}`} />
<div className="min-w-0">
<span className={`font-bold text-xs block leading-tight ${isSelected ? 'text-purple-950' : 'text-slate-800'}`}>{r.label}</span>
<span className="text-[10px] text-slate-455 leading-tight block mt-1 font-medium">{r.desc}</span>
</div>
</button>
);
})}
</div>
</div>
{/* RadioList, not the old two-column grid of buttons. This is
a single-select and each choice carries a description, which
RadioListItem takes directly. As buttons the selection lived
only in border colour and announced nothing. The per-role
icons are dropped — RadioListItem has no icon slot, and real
radio semantics are worth more here than the glyph. */}
<RadioList
label="Select account role"
isRequired
value={String(newUser.roleid)}
onChange={(v) => setNewUser({ ...newUser, roleid: Number(v) })}
>
{roleChoices.map((r) => (
<RadioListItem key={r.id} value={String(r.id)} label={r.label} description={r.desc} />
))}
</RadioList>
{/* Store / branch — binds the user to a specific outlet (tenantid + locationid + applocationid) */}
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">STORE / BRANCH (*)</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Store size={14} />
</span>
<select
value={newUser.locationid}
onChange={(e) => {
const lid = Number(e.target.value);
const s = storeOptions.find((o) => o.locationid === lid);
setNewUser({ ...newUser, locationid: lid, applocationid: s?.applocationid ?? 0 });
}}
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm cursor-pointer"
>
<option value={0}>{locationsQ.isLoading ? 'Loading stores…' : storeOptions.length ? 'Select a store…' : 'No stores found'}</option>
{storeOptions.map((s) => (
<option key={s.locationid} value={s.locationid}>{s.name}{s.address ? ` — ${s.address}` : ''}</option>
))}
</select>
</div>
</div>
<Selector
label="Store / branch" isRequired
value={newUser.locationid ? String(newUser.locationid) : ''}
onChange={(v) => {
const lid = Number(v) || 0;
const s = storeOptions.find((o) => o.locationid === lid);
setNewUser({ ...newUser, locationid: lid, applocationid: s?.applocationid ?? 0 });
}}
isLoading={locationsQ.isLoading}
placeholder={locationsQ.isLoading ? 'Loading stores…' : storeOptions.length ? 'Select a store…' : 'No stores found'}
startIcon={<Store size={14} />}
options={storeOptions.map((s) => ({
value: String(s.locationid),
label: `${s.name}${s.address ? ` — ${s.address}` : ''}`,
}))}
/>
{/* Rider shift — only when a rider role is selected */}
{isRiderRole && (
<div className="space-y-1.5 animate-in slide-in-from-top duration-250">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">RIDER SHIFT</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Clock size={14} />
</span>
<select
value={newUser.shiftid}
onChange={(e) => setNewUser({ ...newUser, shiftid: Number(e.target.value) })}
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm cursor-pointer"
>
<option value={0}>{shiftsQ.isLoading ? 'Loading shifts…' : shiftOptions.length ? 'Select rider shift…' : 'No shifts available'}</option>
{shiftOptions.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
</div>
<div className="animate-in slide-in-from-top duration-250">
<Selector
label="Rider shift"
value={newUser.shiftid ? String(newUser.shiftid) : ''}
onChange={(v) => setNewUser({ ...newUser, shiftid: Number(v) || 0 })}
isLoading={shiftsQ.isLoading}
placeholder={shiftsQ.isLoading ? 'Loading shifts…' : shiftOptions.length ? 'Select rider shift…' : 'No shifts available'}
startIcon={<Clock size={14} />}
options={shiftOptions.map((s) => ({ value: String(s.id), label: s.label }))}
/>
</div>
)}
{/* Address — keyless autocomplete that fills suburb / city / state / postcode */}
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">ADDRESS</label>
<label className="font-bold uppercase tracking-widest text-[10px] opacity-60">Address</label>
<AddressAutocomplete value={newUser.address} onSelect={handleAddressSelect} placeholder="Search address…" />
</div>
<div className="grid grid-cols-2 gap-sm">
{(['suburb', 'city', 'state', 'postcode'] as const).map((key) => (
<div className="space-y-1.5" key={key}>
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">{key.toUpperCase()}</label>
<input
type="text"
value={newUser[key]}
onChange={(e) => setNewUser({ ...newUser, [key]: e.target.value })}
className="w-full border border-slate-200 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
/>
</div>
<TextInput
key={key}
label={key.charAt(0).toUpperCase() + key.slice(1)}
value={newUser[key]}
onChange={(v) => setNewUser({ ...newUser, [key]: v })}
/>
))}
</div>
@@ -655,57 +636,41 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</div>
{/* Modal Footer */}
<div className="p-5 border-t border-slate-100 flex justify-end gap-sm bg-slate-50/50 shrink-0">
<button
type="button"
onClick={() => setShowAddUserModal(false)}
className="px-5 py-2.5 border border-slate-200 hover:bg-slate-100 rounded-xl font-bold text-slate-500 hover:text-slate-700 cursor-pointer active:scale-95 transition-all text-sm"
>
Cancel
</button>
<button
<div className="p-5 flex justify-end gap-sm shrink-0">
<Button type="button" label="Cancel" variant="secondary" onClick={() => setShowAddUserModal(false)} />
<Button
type="submit"
disabled={createUserMut.isPending}
className="px-6 py-2.5 bg-purple-650 hover:bg-purple-755 text-white rounded-xl font-bold cursor-pointer shadow-sm disabled:opacity-60 disabled:cursor-not-allowed active:scale-95 transition-all flex items-center gap-1.5 border-none text-sm"
>
{createUserMut.isPending ? (
<>
<span className="w-2 h-2 rounded-full bg-white animate-pulse" />
Creating…
</>
) : (
<>
<Check size={14} />
Add Member
</>
)}
</button>
label={createUserMut.isPending ? 'Creating…' : 'Add Member'}
icon={<Check size={14} />}
isLoading={createUserMut.isPending}
isDisabled={createUserMut.isPending}
/>
</div>
</form>
</div>
</div>
</UsersModal>
)}
{/* ASSIGN USER MODAL */}
{assignUserId !== null && (
<div
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md select-none"
onClick={(e) => { if (e.target === e.currentTarget) setAssignUserId(null); }}
>
<div className="bg-white border border-slate-200/80 w-full max-w-[28rem] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-sm font-sans cursor-default">
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center shrink-0">
<h4 className="font-bold text-slate-900 flex items-center gap-2.5 text-base">
<div className="w-8 h-8 rounded-lg bg-emerald-50 text-emerald-650 flex items-center justify-center">
<UserCheck size={16} />
</div>
Assign Team Member
</h4>
<button
<UsersModal onClose={() => setAssignUserId(null)} width={448}>
<div className="flex flex-col text-sm font-sans">
<div className="p-5 flex justify-between items-center shrink-0">
<Heading level={3}>
<span className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-emerald-50 text-emerald-650 flex items-center justify-center">
<UserCheck size={16} />
</div>
Assign Team Member
</span>
</Heading>
<IconButton
label="Close assign team member"
variant="ghost"
size="sm"
icon={<X size={18} />}
onClick={() => setAssignUserId(null)}
className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 cursor-pointer transition-colors"
>
<X size={18} />
</button>
/>
</div>
<form onSubmit={async (e) => {
@@ -723,60 +688,38 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
notifyFail(err, 'Could not assign user.');
}
}} className="p-6 space-y-5">
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">ROLE (*)</label>
<div className="relative">
<select
value={assignRoleId}
onChange={(e) => setAssignRoleId(Number(e.target.value))}
className="w-full border border-slate-200 rounded-xl px-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm cursor-pointer"
>
{roleChoices.map((r) => (
<option key={r.id} value={r.id}>{r.label}</option>
))}
</select>
</div>
</div>
<Selector
label="Role" isRequired
value={String(assignRoleId)}
onChange={(v) => setAssignRoleId(Number(v) || assignRoleId)}
options={roleChoices.map((r) => ({ value: String(r.id), label: r.label }))}
/>
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">STORE / LOCATION (*)</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Store size={14} />
</span>
<select
value={assignLocationId}
onChange={(e) => setAssignLocationId(Number(e.target.value))}
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm cursor-pointer"
required
>
<option value={0} disabled>{locationsQ.isLoading ? 'Loading stores…' : 'Select a store…'}</option>
{storeOptions.map((s) => (
<option key={s.locationid} value={s.locationid}>{s.name}{s.address ? ` — ${s.address}` : ''}</option>
))}
</select>
</div>
</div>
<Selector
label="Store / location" isRequired
value={assignLocationId ? String(assignLocationId) : ''}
onChange={(v) => setAssignLocationId(Number(v) || 0)}
isLoading={locationsQ.isLoading}
placeholder={locationsQ.isLoading ? 'Loading stores…' : 'Select a store…'}
startIcon={<Store size={14} />}
options={storeOptions.map((s) => ({
value: String(s.locationid),
label: `${s.name}${s.address ? ` — ${s.address}` : ''}`,
}))}
/>
<div className="pt-4 border-t border-slate-100 flex justify-end gap-sm mt-6">
<button
type="button"
onClick={() => setAssignUserId(null)}
className="px-5 py-2.5 border border-slate-200 hover:bg-slate-100 rounded-xl font-bold text-slate-500 cursor-pointer active:scale-95 transition-all text-sm"
>
Cancel
</button>
<button
<div className="pt-4 flex justify-end gap-sm mt-6">
<Button type="button" label="Cancel" variant="secondary" onClick={() => setAssignUserId(null)} />
<Button
type="submit"
disabled={updateUserMut.isPending || !assignLocationId}
className="px-6 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold cursor-pointer shadow-sm disabled:opacity-60 disabled:cursor-not-allowed active:scale-95 transition-all flex items-center gap-1.5 border-none text-sm"
>
{updateUserMut.isPending ? 'Assigning…' : 'Assign User'}
</button>
label={updateUserMut.isPending ? 'Assigning…' : 'Assign User'}
isLoading={updateUserMut.isPending}
isDisabled={updateUserMut.isPending || !assignLocationId}
/>
</div>
</form>
</div>
</div>
</UsersModal>
)}
</div>
);

View File

@@ -13,6 +13,7 @@
*/
import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query';
import { useNotify } from '../components/Toasts';
import type { Row } from './fiestaApi';
import { loginRequest, matchTenantUser, buildAuthUser, type AuthUser } from './auth';
import {
@@ -1021,6 +1022,10 @@ export function useFiestaGlobalProducts(opts: {
// ── Store Catalogue (Product Locations) ─────────────────────────────────────
export function useFiestaCreateProductLocation() {
const qc = useQueryClient();
// PHASE 6 — the last alert() in the app. It lived here rather than in a
// component, which is why the earlier sweep of 42 missed it. This is already
// a hook, so useNotify() is legal in it.
const { notifyFail } = useNotify();
return useMutation({
mutationFn: (input: CreateProductLocationInput) => createProductLocation(input),
onSuccess: (_, variables) => {
@@ -1030,7 +1035,7 @@ export function useFiestaCreateProductLocation() {
qc.invalidateQueries({ queryKey: ['fiesta', 'stockStatement'] });
},
onError: (error: any) => {
alert(`Error adding product: ${error.message}`);
notifyFail(error, 'Could not add this product.');
},
});
}