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 ThemeShowcase from './components/spike/ThemeShowcase'; // PHASE 2 gate — throwaway
import ConsoleUiProbe from './components/spike/ConsoleUiProbe'; // PHASE 3 verification — throwaway import ConsoleUiProbe from './components/spike/ConsoleUiProbe'; // PHASE 3 verification — throwaway
import { import {
Network,
Truck,
Sliders,
Calendar,
FileCheck,
Building, Building,
CheckCircle2,
Clock,
ShieldCheck,
Send,
HelpCircle,
Database,
ArrowRight, ArrowRight,
X,
Search, Search,
Plus,
MapPin,
Phone, Phone,
Activity, // Passed by reference into the Header's `icon` slot rather than rendered as
TrendingUp, // JSX here, which is why a JSX-only usage scan misses them.
Award, LayoutDashboard,
Layers, Layers,
Store, Store,
TrendingUp,
Settings, Settings,
LayoutDashboard, Truck
Users,
Box
} from 'lucide-react'; } from 'lucide-react';
import { MainSection } from './types'; import { MainSection } from './types';
@@ -43,7 +28,6 @@ import {
useFiestaTenantLocations, useFiestaTenantLocations,
useFiestaLocationSummary, useFiestaLocationSummary,
useFiestaUpdateLocation, useFiestaUpdateLocation,
useFiestaCreateLocation,
useFiestaOrderSummary, useFiestaOrderSummary,
} from './services/fiestaQueries'; } from './services/fiestaQueries';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr, num as fnum } from './services/fiestaApi'; 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 LoginView from './components/LoginView';
import UserStorePage from './components/UserStorePage'; import UserStorePage from './components/UserStorePage';
import SuperAdminPage from './components/SuperAdminPage'; import SuperAdminPage from './components/SuperAdminPage';
import AwaitingApi from './components/AwaitingApi';
import ComparisonModal from './components/ComparisonModal'; import ComparisonModal from './components/ComparisonModal';
import CatalogueBrowser from './components/CatalogueBrowser'; import CatalogueBrowser from './components/CatalogueBrowser';
import type { AuthUser } from './services/auth'; import type { AuthUser } from './services/auth';
import ragulStoreCover from './assets/images/store_front_view_1780299351800.png'; 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'; const AUTH_STORAGE_KEY = 'nearledaily.auth';
@@ -115,6 +106,9 @@ export default function App() {
// Navigation indicators states // Navigation indicators states
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); 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' // Derive current section from URL path, default to 'dashboard'
const pathParts = location.pathname.split('/'); const pathParts = location.pathname.split('/');
@@ -143,9 +137,10 @@ export default function App() {
const tenantId = authUser?.tenantid || FIESTA_TENANT_ID; const tenantId = authUser?.tenantid || FIESTA_TENANT_ID;
// ── Live data for the secondary sections (Fiesta) ───────────────────────── // ── Live data for the secondary sections (Fiesta) ─────────────────────────
// Stores ← tenant locations + per-location order summary (seeded into local // Stores ← tenant locations + per-location order summary, held in local state
// state so the "Add Store" handler keeps working). Users management now lives // so the per-card status toggle can update a row optimistically before the
// under Settings → Users & Access (see UsersPanel). // refetch lands. Users management lives under Settings → Users & Access
// (see UsersPanel); outlet creation lives under Settings → Outlets.
const locationsQ = useFiestaTenantLocations(tenantId); const locationsQ = useFiestaTenantLocations(tenantId);
const locSummaryQ = useFiestaLocationSummary(tenantId); const locSummaryQ = useFiestaLocationSummary(tenantId);
const updateLocation = useFiestaUpdateLocation(); const updateLocation = useFiestaUpdateLocation();
@@ -238,41 +233,19 @@ export default function App() {
} }
}, [locationsQ.data, locSummaryQ.data]); }, [locationsQ.data, locSummaryQ.data]);
// Secondary sub-sections modals triggers // The "Add Store" and "Scheduled Reports Calendar" modals that used to live
const [showAddStoreModal, setShowAddStoreModal] = useState(false); // here are gone, along with their state and `handleCreateStore`. Nothing ever
// opened them: `setShowAddStoreModal(true)` and `setShowCalendarModal(true)`
// New forms states // were called nowhere in the codebase, so both were permanently false and the
const [newStore, setNewStore] = useState({ name: '', zone: '', lead: '', sales: '₹1,50,000' }); // markup was unreachable. `handleCreateStore` was reachable only through one
// of them, and it never wrote to the backend — it pushed a row into local
// Form submission handles for secondary sections // `storesList` with a random `locationid`, which the next refetch discarded.
const handleCreateStore = (e: React.FormEvent) => { // Real outlet creation is Settings → Outlets → Add Store Branch, which goes
e.preventDefault(); // through AdminConsole and the createlocation endpoint.
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);
// Callback action triggers // Callback action triggers
const handleHelp = () => { 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); const handleLogout = () => setAuthUser(null);
@@ -302,86 +275,65 @@ export default function App() {
{/* ── Search & Filter Toolbar ── */} {/* ── 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"> <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 */} {/* Search Input */}
<div className="relative flex-1 w-full group"> <div className="flex-1 w-full">
<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"> <TextInput
<Search className="w-5 h-5" /> label="Search stores"
</div> isLabelHidden
<input
type="text"
placeholder="Search store by name, zone, or manager..." placeholder="Search store by name, zone, or manager..."
value={storesSearch} value={storesSearch}
onChange={(e) => setStoresSearch(e.target.value)} onChange={(v) => setStoresSearch(v)}
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" 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> </div>
{/* Vertical Divider (Hidden on Mobile) */} {/* Vertical Divider (Hidden on Mobile) */}
<div className="hidden lg:block w-px h-8 bg-slate-200" /> <div className="hidden lg:block w-px h-8 bg-slate-200" />
{/* Filter Segmented Control */} {/* PHASE 6 — the three filter buttons were a hand-built segmented
<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"> control: they looked like one but announced as three unrelated
<button buttons with no active state. The counts move into the labels so
onClick={() => setStoresFilter('ALL')} each option's accessible name carries its own number. */}
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 ${ <div className="w-full lg:w-auto shrink-0">
storesFilter === 'ALL' <SegmentedControl
? 'bg-white text-slate-800 shadow-[0_1px_3px_rgba(0,0,0,0.1)] ring-1 ring-slate-900/5' label="Filter stores by status"
: 'text-slate-500 hover:text-slate-700 hover:bg-white/50' size="sm"
}`} value={storesFilter}
onChange={(v) => setStoresFilter(v as typeof storesFilter)}
> >
All Nodes <SegmentedControlItem value="ALL" label={`All Nodes (${storesList.length})`} />
<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'}`}> <SegmentedControlItem
{storesList.length} value="ACTIVE"
</span> label={`Active (${storesList.filter(s => s.status.toLowerCase() === 'active').length})`}
</button> />
<button <SegmentedControlItem
onClick={() => setStoresFilter('ACTIVE')} value="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 ${ label={`Inactive (${storesList.filter(s => s.status.toLowerCase() !== 'active').length})`}
storesFilter === 'ACTIVE' />
? 'bg-white text-emerald-600 shadow-[0_1px_3px_rgba(0,0,0,0.1)] ring-1 ring-emerald-500/20' </SegmentedControl>
: '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>
</div> </div>
</div> </div>
{/* Empty States */} {/* Empty States */}
{filteredStoresList.length === 0 && ( {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 ? ( {locationsQ.isLoading ? (
<div className="flex flex-col items-center justify-center gap-2"> <div className="flex justify-center"><Spinner label="Loading live store locations…" /></div>
<div className="w-6 h-6 border-2 border-[#662582] border-t-transparent rounded-full animate-spin" />
<span>Loading live store locations…</span>
</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> </div>
)} )}
@@ -505,34 +457,41 @@ export default function App() {
</div> </div>
</div> </div>
<button <IconButton
onClick={(e) => { 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(); 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> </div>
{/* Card footer - enter console */} {/* 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"> <div className="flex items-center justify-between text-[10px] font-bold text-[#662582] mt-4 pt-3 border-t border-zinc-100/80">
<button {/* A real Switch. This was a <button> hand-drawn to look
onClick={(e) => { like a toggle: it announced as a button with no on/off
e.stopPropagation(); state, and its only explanation was a `title`. Switch
const newStatus = st.status.toLowerCase() === 'active' ? 'Inactive' : 'Active'; exposes the state via a checkbox role, so the store's
setStoresList(stores => stores.map(s => s.locationid === st.locationid ? { ...s, status: newStatus } : s)); live/inactive status is finally audible — on a control
if (st.locationid) { that takes a shop offline. */}
updateLocation.mutate({ locationid: st.locationid, status: newStatus.toLowerCase() }); <span onClick={(e) => e.stopPropagation()}>
} <Switch
}} label={`${st.name} is ${st.status.toLowerCase() === 'active' ? 'active' : 'inactive'}`}
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'}`} isLabelHidden
title={`Toggle store to ${st.status.toLowerCase() === 'active' ? 'Inactive' : 'Active'}`} size="sm"
> value={st.status.toLowerCase() === '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'}`} /> onChange={(checked) => {
</button> 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"> <div className="flex items-center gap-1 uppercase tracking-wider">
<span>Enter Terminal Console</span> <span>Enter Terminal Console</span>
@@ -709,138 +668,6 @@ export default function App() {
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} /> <Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
</Routes> </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 /> <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 React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { import {
Truck, Clock, CheckCircle2, XCircle, Calendar, Sun, Sunset, Moon, Layers, UserCheck, MapPin, Phone, Package, Loader2, X, Bike, Truck, Clock, CheckCircle2, XCircle, Calendar, Sun, Sunset, Moon, Layers, UserCheck, MapPin, Phone, Package, Loader2, X, Bike,
} from 'lucide-react'; } 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 { 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 { shortTime } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi'; 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 { import {
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE, Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, } 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'; } from './consoleUi';
interface DeliveriesViewProps { searchQuery?: string; locationid?: number; tenantId?: number; applocationid?: number; date?: string; } 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>
<div className="flex items-center gap-2 text-xs"> <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> <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>
</div> </div>
</FilterBar> </FilterBar>
@@ -166,17 +194,17 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
</FilterBar> </FilterBar>
{/* Table */} {/* Table */}
<div className="bg-white border overflow-hidden" style={{ borderColor: BORDER }}> <ShellCard className="overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 1240 }}> <Table style={{ minWidth: 1240 }}>
<thead> <TableHeader>
<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> <TableRow>{['S.No', 'Tenant', 'Order ID', 'Pickup', 'Delivery', 'Rider', 'KMS', 'Amount', 'Status', 'Notes', 'Action'].map((h, i) => (<TableHeaderCell key={i}>{h}</TableHeaderCell>))}</TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{deliveriesQ.isLoading ? ( {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 ? ( ) : 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) => { rows.map((r, i) => {
const st = fstr(r.orderstatus).toLowerCase(); 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 dropAddr = fstr(r.deliveryaddress) || fstr(r.deliverylocation) || fstr(r.deliverysuburb);
const notes = fstr(r.ordernotes) || fstr(r.notes); const notes = fstr(r.ordernotes) || fstr(r.notes);
return ( return (
<tr key={fstr(r.deliveryid) || fstr(r.orderid) || i} className="transition-colors align-top" style={{ borderBottom: `1px solid ${DIVIDER}` }} <TableRow key={fstr(r.deliveryid) || fstr(r.orderid) || i}>
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}> <TableCell><span className="font-mono" style={{ color: TEXT_3 }}>{i + 1}</span></TableCell>
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td> <TableCell>
<td className="px-3 py-2.5">
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{tenant || '—'}</p> <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>} {fstr(r.tenantcity) && <p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.tenantcity)}</p>}
</td> </TableCell>
<td className="px-3 py-2.5"> <TableCell>
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`}</p> <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> <p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.assigntime || r.deliverydate)}</p>
</td> </TableCell>
<td className="px-3 py-2.5"> <TableCell>
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{pickupName || pickupAddr || '—'}</p> <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> <p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{pickupName ? pickupAddr : ''}</p>
</td> </TableCell>
<td className="px-3 py-2.5"> <TableCell>
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{dropName || dropAddr || '—'}</p> <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> <p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{dropName ? dropAddr : ''}</p>
</td> </TableCell>
<td className="px-3 py-2.5"> <TableCell>
{rider ? ( {rider ? (
<span className="inline-flex items-center gap-2"> <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="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 className="font-bold text-[12px] truncate max-w-[100px]" style={{ color: TEXT }}>{rider}</span>
</span> </span>
) : <span className="text-[11px] italic" style={{ color: TEXT_3 }}>Unassigned</span>} ) : <span className="text-[11px] italic" style={{ color: TEXT_3 }}>Unassigned</span>}
</td> </TableCell>
<td className="px-3 py-2.5"> <TableCell>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<MetricPill color="#ef4444" minWidth={64}>{kms ? kms.toFixed(1) : '—'}</MetricPill> <MetricPill color="#ef4444" minWidth={64}>{kms ? kms.toFixed(1) : '—'}</MetricPill>
{actualKms > 0 && <MetricPill color="#10b981" minWidth={64}>{actualKms.toFixed(1)}</MetricPill>} {actualKms > 0 && <MetricPill color="#10b981" minWidth={64}>{actualKms.toFixed(1)}</MetricPill>}
</div> </div>
</td> </TableCell>
<td className="px-3 py-2.5"> <TableCell>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
{charge > 0 && <MetricPill color="#ef4444" minWidth={72}>₹{charge.toLocaleString('en-IN')}</MetricPill>} {charge > 0 && <MetricPill color="#ef4444" minWidth={72}>₹{charge.toLocaleString('en-IN')}</MetricPill>}
{amt > 0 && <MetricPill color="#10b981" minWidth={72}>₹{amt.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>} {charge === 0 && amt === 0 && <span style={{ color: TEXT_3 }}>—</span>}
</div> </div>
</td> </TableCell>
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} /></td> <TableCell><StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} /></TableCell>
<td className="px-3 py-2.5"> <TableCell>
<p className="text-[11px] truncate max-w-[160px]" style={{ color: notes ? TEXT_2 : TEXT_3 }} title={notes}>{notes || '—'}</p> <p className="text-[11px] truncate max-w-[160px]" style={{ color: notes ? TEXT_2 : TEXT_3 }} title={notes}>{notes || '—'}</p>
</td> </TableCell>
<td className="px-3 py-2.5 text-right"> <TableCell>
<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> <div className="text-right">
</td> <Button label="View" aria-label={`View delivery ${fstr(r.orderid) || fstr(r.deliveryid)}`} variant="secondary" size="sm" onClick={() => setDetailRow(r)} />
</tr> </div>
</TableCell>
</TableRow>
); );
}) })
)} )}
</tbody> </TableBody>
</table> </Table>
</div> </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 }}> <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 {rows.length} {status} · {BATCHES.find((b) => b.id === batch)?.label} wave
</div> </div>
</div> </ShellCard>
{detailRow && <DeliveryDetailModal row={detailRow} riders={ridersQ.data ?? []} onClose={() => setDetailRow(null)} />} {detailRow && <DeliveryDetailModal row={detailRow} riders={ridersQ.data ?? []} onClose={() => setDetailRow(null)} />}
</div> </div>
@@ -329,24 +358,27 @@ function RiderActions({ row, riders }: { row: Row; riders: Row[] }) {
{canChange ? ( {canChange ? (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<select {/* The "no riders on duty" wording carried real information, so it
value={picked} moves to `placeholder` rather than being flattened away. */}
onChange={(e) => setPicked(Number(e.target.value))} <div className="min-w-[180px] max-w-[220px]">
disabled={busy} <Selector
className="rounded-full font-bold text-[11px] outline-none cursor-pointer disabled:opacity-50" label="Change rider"
style={{ padding: '6px 10px', border: `1px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 200 }} isLabelHidden
> size="sm"
<option value={0}>{options.length ? 'Change rider…' : 'No riders on duty'}</option> value={picked ? String(picked) : ''}
{options.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)} onChange={(v) => setPicked(Number(v) || 0)}
</select> isDisabled={busy}
<button 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} 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> </div>
) : ( ) : (
<p className="text-[11px] font-medium" style={{ color: TEXT_3 }}> <p className="text-[11px] font-medium" style={{ color: TEXT_3 }}>
@@ -358,25 +390,27 @@ function RiderActions({ row, riders }: { row: Row; riders: Row[] }) {
{canNotify && ( {canNotify && (
<div className="flex flex-wrap items-center gap-2 mt-2 pt-2" style={{ borderTop: `1px solid ${DIVIDER}` }}> <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)} onClick={() => push(currentToken, RIDER_MESSAGES.reminder)}
disabled={busy} />
className="rounded-full font-bold cursor-pointer disabled:opacity-40" {/* Destructive: this tells the rider app to drop the delivery. It was
style={{ padding: '5px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }} styled red but rendered as an ordinary button, and its only
> explanation was a `title` — which a disabled control never shows. */}
Notify rider <span title="Tells the rider app to drop this delivery">
</button> <Button
<button label="Send cancellation"
onClick={() => variant="destructive"
push(currentToken, RIDER_MESSAGES.cancelled(fstr(row.orderid) || `DLV-${deliveryid}`), { type: 'cancel' }, 'Cancellation sent') size="sm"
} isDisabled={busy}
disabled={busy} onClick={() =>
title="Tells the rider app to drop this delivery" push(currentToken, RIDER_MESSAGES.cancelled(fstr(row.orderid) || `DLV-${deliveryid}`), { type: 'cancel' }, 'Cancellation sent')
className="rounded-full font-bold cursor-pointer disabled:opacity-40" }
style={{ padding: '5px 12px', fontSize: 11, color: '#b91c1c', background: '#fef2f2', border: '1px solid #fecaca' }} />
> </span>
Send cancellation
</button>
</div> </div>
)} )}
@@ -401,15 +435,21 @@ function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]
{ label: 'Picked', field: 'pickuptime' }, { label: 'Delivered', field: 'deliverytime' }, { label: 'Picked', field: 'pickuptime' }, { label: 'Delivered', field: 'deliverytime' },
]; ];
// Portal to <body> so `fixed inset-0` is viewport-relative even when an ancestor // PHASE 6 — the portal and the `width: min(32rem, 92vw)` are gone with the
// in the view tree is transformed/blurred (otherwise the panel collapses). // overlay. Both existed for one reason: `fixed inset-0` resolves against the
return createPortal( // nearest transformed/filtered ancestor rather than the viewport, and this
<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(); }}> // page sits inside several, so the panel used to collapse. Dialog renders in
<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)' }}> // the browser's top layer, which no ancestor can contain.
<div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)` }} /> return (
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}> <Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
<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> <div className="flex flex-col max-h-[90vh]">
<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">
<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>
<div className="p-4 space-y-4 overflow-y-auto flex-1"> <div className="p-4 space-y-4 overflow-y-auto flex-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -451,11 +491,10 @@ function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]
</div> </div>
<RiderActions row={row} riders={riders} /> <RiderActions row={row} riders={riders} />
</div> </div>
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}> <div className="p-3 flex justify-end shrink-0">
<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> <Button label="Close" onClick={onClose} />
</div> </div>
</div> </div>
</div>, </Dialog>
document.body,
); );
} }

View File

@@ -25,6 +25,15 @@ import {
} from './consoleUi'; } from './consoleUi';
import SalesRevenueReport from './SalesRevenueReport'; 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'; type ReportTab = 'orders-summary' | 'riders-summary';
const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> = [ const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> = [
@@ -55,19 +64,17 @@ export default function DeliveryReportsView({ searchQuery = '', tenantId = FIEST
return ( return (
<div className="animate-in fade-in duration-300"> <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"> {/* PHASE 6 — two underline-styled buttons standing in for a tab bar; the
<button active one was marked by a border colour and nothing else. */}
onClick={() => setActiveTab('overview')} <div className="border-b border-slate-200/70 pb-2 mb-4">
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'}`} <SegmentedControl
label="Report section"
value={activeTab}
onChange={(v) => setActiveTab(v as typeof activeTab)}
> >
Overview <SegmentedControlItem value="overview" label="Overview" />
</button> <SegmentedControlItem value="sales_revenue" label="Sales &amp; Revenue" />
<button </SegmentedControl>
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>
</div> </div>
{activeTab === 'overview' ? ( {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> <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> </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"> <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> <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> </div>
</div> </div>
@@ -202,8 +227,21 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
{/* Revenue */} {/* 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'}`}> 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"> <div className="flex justify-between items-start w-full">
<Tooltip content="Total revenue collected across all orders (excluding cancelled) in this period." position="bottom"> <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> <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> </button>
{/* Orders */} {/* 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"> <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"> <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> <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> </div>
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden"> <ShellCard className="overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm text-left"> <Table className="text-sm">
<thead className="bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-500 uppercase tracking-wider"> <TableHeader>
<tr> <TableRow>
<th className="px-5 py-4 w-12">#</th> <TableHeaderCell>#</TableHeaderCell>
<th className="px-5 py-4">Outlet</th> <TableHeaderCell>Outlet</TableHeaderCell>
<th className="px-5 py-4 text-right">Revenue</th> <TableHeaderCell>Revenue</TableHeaderCell>
<th className="px-5 py-4 text-right">AOV</th> <TableHeaderCell>AOV</TableHeaderCell>
<th className="px-5 py-4 text-right">Orders</th> <TableHeaderCell>Orders</TableHeaderCell>
<th className="px-5 py-4 text-right">Fulfillment</th> <TableHeaderCell>Fulfillment</TableHeaderCell>
<th className="px-5 py-4 text-right">Cancel Rate</th> <TableHeaderCell>Cancel Rate</TableHeaderCell>
</tr> </TableRow>
</thead> </TableHeader>
<tbody className="divide-y divide-slate-100"> <TableBody>
{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> {q.isLoading || revenueQ.isLoading ? (
: 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> <TableRow><TableCell colSpan={7}>
: rows.map((r, i) => { <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 rowRev = locationRevenueMap.get(fnum(r.locationid)) ?? 0;
const rowAov = r.total > 0 ? rowRev / r.total : 0; const rowAov = r.total > 0 ? rowRev / r.total : 0;
const rowFulfill = r.total > 0 ? (r.delivered / r.total) * 100 : 0; const rowFulfill = r.total > 0 ? (r.delivered / r.total) * 100 : 0;
const rowCancel = r.total > 0 ? (r.cancelled / r.total) * 100 : 0; const rowCancel = r.total > 0 ? (r.cancelled / r.total) * 100 : 0;
return ( return (
<tr key={r.locationid || i} className="hover:bg-slate-50 transition-colors"> <TableRow key={r.locationid || i}>
<td className="px-5 py-3 text-slate-400 font-medium">{i + 1}</td> <TableCell><span className="text-slate-400 font-medium">{i + 1}</span></TableCell>
<td className="px-5 py-3 font-semibold text-slate-900">{r.locationname || `Location ${r.locationid}`}</td> <TableCell><span className="font-semibold">{r.locationname || `Location ${r.locationid}`}</span></TableCell>
<td className="px-5 py-3 text-right font-bold text-indigo-600">₹{rowRev.toLocaleString('en-IN')}</td> <TableCell><div className="text-right font-bold text-indigo-600">₹{rowRev.toLocaleString('en-IN')}</div></TableCell>
<td className="px-5 py-3 text-right text-slate-600 font-medium">₹{Math.round(rowAov).toLocaleString('en-IN')}</td> <TableCell><div className="text-right font-medium">₹{Math.round(rowAov).toLocaleString('en-IN')}</div></TableCell>
<td className="px-5 py-3 text-right font-bold text-slate-900">{r.total.toLocaleString('en-IN')}</td> <TableCell><div className="text-right font-bold">{r.total.toLocaleString('en-IN')}</div></TableCell>
<td className="px-5 py-3 text-right"> <TableCell>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<span className="text-xs font-bold text-emerald-600">{rowFulfill.toFixed(0)}%</span> <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="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 className="h-full bg-emerald-500 rounded-full" style={{ width: `${rowFulfill}%` }} />
</div> </div>
</div> </div>
</td> </TableCell>
<td className="px-5 py-3 text-right"> <TableCell>
<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'}`}> {/* Over 10% was rose, otherwise grey — that is a
{rowCancel.toFixed(1)}% threshold, so it reads as error vs neutral. */}
</span> <div className="text-right">
</td> <Badge variant={rowCancel > 10 ? 'error' : 'neutral'} label={`${rowCancel.toFixed(1)}%`} />
</tr> </div>
</TableCell>
</TableRow>
); );
})} })}
</tbody> </TableBody>
</table> </Table>
</div> </div>
{rows.length > 0 && ( {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"> <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> <span className="text-amber-600 font-bold">{totals.pending} pending</span>
</div> </div>
)} )}
</div> </ShellCard>
{/* Insights Bento */} {/* Insights Bento */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6"> <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 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 DispatchView from './DispatchView';
import OrdersView from './OrdersView'; import OrdersView from './OrdersView';
import DeliveriesView from './DeliveriesView'; 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 { FIESTA_TENANT_ID, ymd, num as fnum, str as fstr } from '../services/fiestaApi';
import type { Row } from '../services/fiestaApi'; import type { Row } from '../services/fiestaApi';
import './DispatchView.css'; // For #hdr and date-chip styles 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 { interface DispatchHubViewProps {
locationid?: number; locationid?: number;
tenantId?: 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) { export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_ID }: DispatchHubViewProps) {
const [activeTab, setActiveTab] = useState<'map' | 'orders' | 'deliveries'>('map'); const [activeTab, setActiveTab] = useState<'map' | 'orders' | 'deliveries'>('map');
@@ -55,8 +58,6 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
}, [locationsQ.data, locationid]); }, [locationsQ.data, locationid]);
const isToday = date === ymd(today); 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 shiftDate = (delta: number) => {
const d = new Date(`${date}T00:00:00`); const d = new Date(`${date}T00:00:00`);
d.setDate(d.getDate() + delta); d.setDate(d.getDate() + delta);
@@ -103,86 +104,75 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
</span> </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' : ''}`}> {/* PHASE 6 — the date stepper.
<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"> The picker used to be an `opacity-0` <input type="date"> stretched
<ChevronLeft size={16} /> over a custom display, so the same date was rendered twice: once
</button> as `prettyDate` for the eye and once inside an invisible control.
<div className="relative flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-0.5 sm:py-1"> DateInput formats and announces it itself, so the duplicate
<Calendar size={13} className="text-slate-400 hidden sm:block" /> display goes and the TODAY marker becomes a Badge beside it.
<div className="flex flex-col"> The two chevrons had only a `title` — no accessible name. */}
<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"> <div className="flex items-center gap-1.5">
Date {isToday && <span className="text-[7px] sm:text-[8px] bg-blue-100 text-blue-600 px-1 rounded-sm">TODAY</span>} <IconButton
</span> label="Previous day"
<span className="text-[11px] sm:text-xs font-bold text-slate-700 leading-none">{prettyDate}</span> variant="ghost"
</div> size="sm"
<input icon={<ChevronLeft size={16} />}
type="date" onClick={() => shiftDate(-1)}
value={date} />
max={ymd(today)} <div className="w-40 sm:w-44">
onChange={(e) => setDate(e.target.value)} <DateInput
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full" label="Dispatch date"
aria-label="Pick date" isLabelHidden
size="sm"
value={date as ISODateString}
max={ymd(today) as ISODateString}
onChange={(v) => { if (v) setDate(v); }}
/> />
</div> </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"> {isToday && <Badge variant="info" label="Today" />}
<ChevronRight size={16} /> <IconButton
</button> label="Next day"
variant="ghost"
size="sm"
icon={<ChevronRight size={16} />}
isDisabled={isToday}
onClick={() => shiftDate(1)}
/>
</div> </div>
</div> </div>
</div> </div>
{/* ── Unified Tab Row ── */} {/* ── 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 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"> {/* Both rows were hand-built segmented controls — three and two
<button buttons styled to look like one control, announcing as unrelated
onClick={() => setActiveTab('map')} buttons with the selection carried only in background colour. */}
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 ${ <div className="shrink-0">
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' <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 <SegmentedControlItem value="map" label="Map" icon={<Route size={13} />} />
</button> <SegmentedControlItem value="orders" label="Orders" icon={<ShoppingBag size={13} />} />
<SegmentedControlItem value="deliveries" label="Deliveries" icon={<Truck size={13} />} />
<button </SegmentedControl>
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>
</div> </div>
{/* Sub-tabs for Map View — only meaningful on the Map tab; they used to {/* Sub-tabs for Map View — only meaningful on the Map tab; they used to
stay visible (and inert) on Orders and Deliveries. */} stay visible (and inert) on Orders and Deliveries. */}
{activeTab === 'map' && ( {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"> <div className="ml-3 sm:ml-4 shrink-0">
<button <SegmentedControl
onClick={() => setMapViewMode('stores')} label="Map grouping"
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 ${ size="sm"
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' value={mapViewMode}
}`} onChange={(v) => setMapViewMode(v as typeof mapViewMode)}
> >
<Store size={13} className={mapViewMode === 'stores' ? 'text-blue-500' : 'text-slate-400'} /> By Store <SegmentedControlItem value="stores" label="By Store" icon={<Store size={13} />} />
</button> <SegmentedControlItem value="customers" label="By Customer" icon={<Users size={13} />} />
</SegmentedControl>
<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>
</div> </div>
)} )}
</div> </div>

View File

@@ -4,39 +4,20 @@
*/ */
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Layers, Layers,
Search, Search,
Plus, Plus,
RefreshCw,
AlertTriangle,
TrendingUp,
Sparkles,
Check, Check,
Package, Package,
ChevronRight,
TrendingDown,
Trash2,
PackageCheck,
ShieldCheck, ShieldCheck,
Tag, Tag,
UploadCloud, UploadCloud,
FileSpreadsheet,
Palette,
Info,
X, X,
Server,
ChevronDown,
ChevronUp,
CheckCircle, CheckCircle,
ShoppingCart,
Inbox, Inbox,
Store, Store,
Activity,
Award,
Filter, Filter,
Box,
ArrowLeft ArrowLeft
} from 'lucide-react'; } from 'lucide-react';
import { ProductMatrixItem } from '../types'; import { ProductMatrixItem } from '../types';
@@ -55,7 +36,21 @@ import { useStoreCatalogue, usePriceEverywhere, isPublishedItem } from '../servi
import { CardCarousel, ProductGallery } from './CatalogueGallery'; import { CardCarousel, ProductGallery } from './CatalogueGallery';
import BulkCartDrawer from './BulkCartDrawer'; import BulkCartDrawer from './BulkCartDrawer';
import AwaitingApi from './AwaitingApi'; 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 FMCGHoverOverlay from './FMCGHoverOverlay';
import CatalogueDetailPanel from './CatalogueDetailPanel'; import CatalogueDetailPanel from './CatalogueDetailPanel';
import { useNotify } from './Toasts'; import { useNotify } from './Toasts';
@@ -86,7 +81,6 @@ export default function InventoryView({
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [showCatalogueModal, setShowCatalogueModal] = useState(false); const [showCatalogueModal, setShowCatalogueModal] = useState(false);
const [showOfflineSales, setShowOfflineSales] = useState(false); const [showOfflineSales, setShowOfflineSales] = useState(false);
const navigate = useNavigate();
// ── Live stock across every outlet (Fiesta) ─────────────────────────────── // ── Live stock across every outlet (Fiesta) ───────────────────────────────
// This page is the admin's command surface. The GLOBAL CATALOG is the deduped // 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"> <div className="mb-2 z-40 relative">
{/* Small card metrics grid */} {/* Small card metrics grid */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3"> <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 */} {/* 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"> <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"> <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" /> <Package className="w-3.5 h-3.5" />
</div> </div>
</div> </div>
<div className="mt-0.5"> <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}</h3>
{products.length} <p className="text-[9px] font-semibold mt-0.5 opacity-50">Master catalogue</p>
</h3>
<p className="text-[9px] text-slate-400 font-semibold mt-0.5">Master catalogue</p>
</div> </div>
</div> </ShellCard>
{/* Card 2: Synced Outlets */} {/* 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"> <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"> <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" /> <Layers className="w-3.5 h-3.5" />
</div> </div>
</div> </div>
<div className="mt-0.5"> <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">{locations.length}</h3>
{locations.length} <p className="text-[9px] font-semibold mt-0.5 opacity-50">Synced locations</p>
</h3>
<p className="text-[9px] text-slate-400 font-semibold mt-0.5">Synced locations</p>
</div> </div>
</div> </ShellCard>
{/* Card 3: Total On-Hand Volume */} {/* 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"> <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"> <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" /> <Check className="w-3.5 h-3.5" />
</div> </div>
</div> </div>
<div className="mt-0.5"> <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) => { {storesStock.reduce((total, store) => {
return total + (store.rows || []).reduce((subTotal, r) => { return total + (store.rows || []).reduce((subTotal, r) => {
const inv = stockRowToInventory(r, store.locationname); const inv = stockRowToInventory(r, store.locationname);
@@ -481,25 +473,25 @@ export default function InventoryView({
}, 0); }, 0);
}, 0).toLocaleString('en-IN')} }, 0).toLocaleString('en-IN')}
</h3> </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>
</div> </ShellCard>
{/* Card 4: Catalog Health */} {/* 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"> <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"> <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" /> <ShieldCheck className="w-3.5 h-3.5" />
</div> </div>
</div> </div>
<div className="mt-0.5"> <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%'} {products.length > 0 ? `${Math.round((products.filter(p => p.verified).length / products.length) * 100)}%` : '100%'}
</h3> </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>
</div> </ShellCard>
</div> </div>
</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"> <div className="flex flex-col xl:flex-row gap-6 mt-2 flex-1 min-h-0 overflow-hidden pb-4">
{isLocalSidebarOpen && ( {isLocalSidebarOpen && (
<div className="w-full xl:w-52 shrink-0 flex flex-col gap-5 pr-2 pb-2"> <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"> <ShellCard className="overflow-hidden flex-1 min-h-0 flex flex-col">
<div className="p-4 bg-slate-50 border-b border-slate-100 shrink-0"> <div className="p-4 border-b border-slate-100 shrink-0">
<h3 className="font-bold text-sm text-slate-800 flex items-center gap-2"> <h3 className="font-bold text-sm flex items-center gap-2">
<Filter size={16} className="text-[#662582]" /> Filter Product <Filter size={16} className="text-[#662582]" /> Filter Product
</h3> </h3>
</div> </div>
<div className="p-4 space-y-5 overflow-y-auto custom-scrollbar"> <div className="p-4 space-y-5 overflow-y-auto custom-scrollbar">
<div> <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 <Layers size={14} className="text-[#662582]" /> Categories
</h3> </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"> <div className="space-y-1.5">
{categories.map((cat) => ( {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 key={cat} className="p-2 rounded-xl">
<div className="relative flex items-center justify-center"> <CheckboxInput
<input label={cat}
type="checkbox" value={selectedCategories.includes(cat)}
checked={selectedCategories.includes(cat)} onChange={() => toggleCategory(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" </div>
/>
<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> </div>
</div> </div>
{(selectedCategories.length > 0 || localSearch) && ( {(selectedCategories.length > 0 || localSearch) && (
<button <div className="mt-4">
onClick={() => { setSelectedCategories([]); setLocalSearch(''); }} <Button
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" label="Clear all filters"
> variant="secondary"
Clear All Filters size="sm"
</button> onClick={() => { setSelectedCategories([]); setLocalSearch(''); }}
/>
</div>
)} )}
</div> </div>
</div> </ShellCard>
</div> </div>
)} )}
@@ -572,54 +564,53 @@ export default function InventoryView({
</div> </div>
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto shrink-0"> <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)} 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 <div className="flex-1 sm:flex-none w-full sm:w-32 xl:w-48">
</button> <TextInput
label="Search catalogue"
<div className="relative flex-1 sm:flex-none w-full sm:w-32 xl:w-48 h-[32px]"> isLabelHidden
<input size="sm"
type="text"
placeholder="Search..." placeholder="Search..."
value={localSearch} value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)} onChange={(v) => setLocalSearch(v)}
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" 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> </div>
<button {/* The request count moves out of the button's text and into a
onClick={() => setActiveTab('requests')} Badge — a Button's label is its accessible name, and
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" "Stock Requests 3" is not one. */}
> <div className="flex items-center gap-1">
<Inbox size={12} /> Stock Requests <Button
{storeRequests.length > 0 && ( label="Stock Requests"
<span className="ml-1 bg-rose-500 text-white text-[8px] px-1 py-0.5 rounded-sm leading-none">{storeRequests.length}</span> variant="secondary"
)} size="sm"
</button> icon={<Inbox size={12} />}
<button 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)} 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" <Button
> label="Import Product"
<UploadCloud size={12} /> Offline Sales size="sm"
</button> icon={<Plus size={12} />}
<button
onClick={() => setActiveTab('global_catalogue')} 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>
</div> </div>
@@ -640,7 +631,16 @@ export default function InventoryView({
))} ))}
</div> </div>
) : filteredProducts.length === 0 ? ( ) : 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"> <div className="flex flex-col xl:flex-row gap-6">
{/* Left Side: Normal Catalogue */} {/* Left Side: Normal Catalogue */}
@@ -688,35 +688,41 @@ export default function InventoryView({
</div> </div>
{isPublished ? ( {isPublished ? (
<button <Button
onClick={(e) => { e.stopPropagation(); storeCat.remove(prod.id); }} label="Remove from Store"
title="Remove from store catalogue" variant="secondary"
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" size="sm"
> icon={<CheckCircle size={12} />}
<CheckCircle size={12} /> onClick={(e: React.MouseEvent) => { e.stopPropagation(); storeCat.remove(prod.id); }}
Remove from Store />
</button>
) : addingPriceProdId === 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()}> <div className="flex flex-col gap-1.5 p-2 rounded-none" style={{ background: 'rgba(127,127,127,0.06)' }} onClick={(e) => e.stopPropagation()}>
<label className="text-[9px] font-extrabold text-slate-600 uppercase tracking-widest text-center"> <span className="text-[8px] font-bold uppercase tracking-wider text-center opacity-60">
Set Selling Price (₹)
</label>
<span className="text-[8px] font-bold text-slate-400 uppercase tracking-wider text-center -mt-1">
{allLocationIds.length === 1 ? 'Your store' : `All ${allLocationIds.length} stores`} {allLocationIds.length === 1 ? 'Your store' : `All ${allLocationIds.length} stores`}
</span> </span>
<input {/* NumberInput carries its own label, so the
type="number" hand-rolled <label> above it is gone rather
value={cardImportPrice} than announced twice. */}
onChange={(e) => setCardImportPrice(e.target.value)} <NumberInput
placeholder="e.g. 50" label="Set Selling Price (₹)"
className="px-2 py-1 border border-slate-200 rounded-none text-[10px] font-bold focus:outline-none focus:border-[#662582]" size="sm"
autoFocus value={cardImportPrice === '' ? null : Number(cardImportPrice)}
onChange={(v) => setCardImportPrice(v == null ? '' : String(v))}
min={0}
/> />
<div className="flex gap-1"> <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
<button label="Cancel"
disabled={!cardImportPrice || Number(cardImportPrice) <= 0 || publishMut.isPending} variant="secondary"
onClick={(e) => { 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(); e.stopPropagation();
// Prices AND publishes, in one server-side step. // 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>
</div> </div>
) : ( ) : (
<button <Button
onClick={(e) => { label="Add to Store"
e.stopPropagation(); 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))); 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>
</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-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 flex-col lg:flex-row lg:justify-between lg:items-end gap-4 mb-2">
<div className="flex items-center gap-4"> <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')} 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"> <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} /> <Inbox size={24} className="text-[#662582]" strokeWidth={2.5} />
</div> </div>
@@ -806,172 +811,160 @@ export default function InventoryView({
</div> </div>
</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="flex items-center gap-3">
<div className="relative group"> <div className="w-48">
<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" /> <Selector
<select label="Filter by store"
isLabelHidden
value={requestStoreFilter} value={requestStoreFilter}
onChange={(e) => setRequestStoreFilter(e.target.value)} onChange={(v) => setRequestStoreFilter(v || 'All Stores')}
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" startIcon={<Store size={14} />}
> options={['All Stores', ...requestingStores]}
<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>
</div> </div>
<div className="relative group"> <div className="w-44">
<select <Selector
label="Filter by status"
isLabelHidden
value={requestStatusFilter} value={requestStatusFilter}
onChange={(e) => setRequestStatusFilter(e.target.value)} onChange={(v) => setRequestStatusFilter(v || 'All Statuses')}
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" options={[
> { value: 'All Statuses', label: 'All Statuses' },
<option value="All Statuses">All Statuses</option> { value: 'pending', label: 'Pending' },
<option value="pending">Pending</option> { value: 'approved', label: 'Approved' },
<option value="approved">Approved</option> { value: 'rejected', label: 'Rejected' },
<option value="rejected">Rejected</option> { value: 'received', label: 'Received' },
<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>
</div> </div>
<div className="flex items-center gap-2"> <div className="w-44">
<input <DateInput
type="date" label="Request date"
value={requestDate} isLabelHidden
onChange={(e) => setRequestDate(e.target.value)} value={requestDate as ISODateString}
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" onChange={(v) => { if (v) setRequestDate(v); }}
/> />
</div> </div>
</div> </div>
</div> </div>
{flattenedRequests.length === 0 ? ( {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"> <ShellCard className="py-20">
<div className="w-20 h-20 rounded-full bg-slate-100/80 flex items-center justify-center mb-6 shadow-inner"> <EmptyState
<Inbox size={32} className="text-slate-400" /> icon={<Inbox size={32} />}
</div> title="No Pending Requests"
<h3 className="text-xl font-bold text-slate-700 tracking-tight">No Pending Requests</h3> description="You're all caught up! All store stock requests have been processed and there is currently no pending action required."
<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. </ShellCard>
</p>
</div>
) : ( ) : (
<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"> <div className="overflow-x-auto">
<table className="w-full text-left border-collapse" style={{ minWidth: 960 }}> <Table style={{ minWidth: 960 }}>
<thead> <TableHeader>
<tr className="bg-slate-50/80 border-b border-slate-200"> <TableRow>
{['Requested At', 'Store', 'Product', 'Qty', 'Status', 'Resolved At', ''].map((h, i) => ( {['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> </TableRow>
</thead> </TableHeader>
<tbody className="divide-y divide-slate-100"> <TableBody>
{flattenedRequests.map((req, idx) => { {flattenedRequests.map((req, idx) => {
const isApproved = req.pickData.status === 'Approved';
const isRejected = req.pickData.status === 'Rejected';
const isPending = req.pickData.status === 'Pending'; const isPending = req.pickData.status === 'Pending';
const isCancelled = req.pickData.status === 'Cancelled'; // The five-way class ladder collapses to a Badge variant.
const isReceived = req.pickData.status === 'Received'; // '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 ( return (
<tr <TableRow
key={`${req.locationid}-${req.productid}-${idx}`} key={`${req.locationid}-${req.requestid}`}
className="transition-all duration-200 hover:bg-slate-50/80 group cursor-pointer" className="group cursor-pointer"
onClick={() => setSelectedRequest(req)} onClick={() => setSelectedRequest(req)}
> >
<td className="px-5 py-4 whitespace-nowrap"> <TableCell>
<div className="flex flex-col"> <div className="flex flex-col whitespace-nowrap">
<span className="text-sm font-semibold text-slate-900"> <span className="text-sm font-semibold">
{req.pickData.requestedAt ? new Date(req.pickData.requestedAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'} {req.pickData.requestedAt ? new Date(req.pickData.requestedAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'}
</span> </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' }) : ''} {req.pickData.requestedAt ? new Date(req.pickData.requestedAt).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }) : ''}
</span> </span>
</div> </div>
</td> </TableCell>
<td className="px-5 py-4 whitespace-nowrap"> <TableCell>
<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"> <span className="inline-flex items-center gap-2 whitespace-nowrap">
<Store size={14} className="text-indigo-500" /> <Store size={14} className="text-indigo-500" />
<span className="font-bold text-xs">{req.locationname}</span> <span className="font-bold text-xs">{req.locationname}</span>
</div> </span>
</td> </TableCell>
<td className="px-5 py-4"> <TableCell>
<div className="flex items-center gap-3"> <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"> <div className="relative w-10 h-10 rounded-xl overflow-hidden shrink-0">
<img src={req.product?.image} alt={req.product?.name} className="w-full h-full object-cover" /> <img src={req.product?.image} alt="" className="w-full h-full object-cover" />
</div> </div>
<div className="min-w-0 flex-1"> <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="font-bold text-sm truncate">{req.product?.name}</p>
<p className="text-xs font-medium text-slate-500 truncate mt-0.5">{req.product?.sku}</p> <p className="text-xs font-medium truncate mt-0.5 opacity-60">{req.product?.sku}</p>
</div> </div>
</div> </div>
</td> </TableCell>
<td className="px-5 py-4 whitespace-nowrap"> <TableCell>
<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"> <span className="font-bold text-sm tabular-nums">{req.pickData.qty || '—'}</span>
{req.pickData.qty || '—'} </TableCell>
</span> <TableCell>
</td> <Badge variant={statusVariant} label={req.pickData.status || '—'} />
<td className="px-5 py-4 whitespace-nowrap"> </TableCell>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${ <TableCell>
isReceived ? 'bg-indigo-50 text-indigo-700 border-indigo-200' : <span className="text-xs font-medium whitespace-nowrap opacity-60">
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">
{req.pickData.resolvedAt ? new Date(req.pickData.resolvedAt).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'} {req.pickData.resolvedAt ? new Date(req.pickData.resolvedAt).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'}
</span> </span>
</td> </TableCell>
<td className="px-5 py-4 whitespace-nowrap text-right"> <TableCell>
{isPending ? ( {isPending ? (
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> // The `opacity-0 group-hover:opacity-100` reveal is
<button // gone: it hid the only two actions on the row from
onClick={(e) => { e.stopPropagation(); updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Approved'); }} // anyone not using a mouse, and a keyboard user
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" // could tab to an invisible button.
title="Approve Request" <div className="flex items-center justify-end gap-2">
> <Button
<CheckCircle size={14} /> Approve label="Approve"
</button> variant="secondary"
<button size="sm"
onClick={(e) => { e.stopPropagation(); updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Rejected'); }} icon={<CheckCircle size={14} />}
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" onClick={(e: React.MouseEvent) => { e.stopPropagation(); updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Approved'); }}
title="Reject Request" />
> <Button
<X size={14} /> Reject label="Reject"
</button> variant="destructive"
size="sm"
icon={<X size={14} />}
onClick={(e: React.MouseEvent) => { e.stopPropagation(); updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Rejected'); }}
/>
</div> </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 Processed
</span> </span>
)} )}
</td> </TableCell>
</tr> </TableRow>
); );
})} })}
</tbody> </TableBody>
</table> </Table>
</div> </div>
</div> </ShellCard>
)} )}
</div> </div>
) : activeTab === 'global_catalogue' ? ( ) : activeTab === 'global_catalogue' ? (
@@ -1070,23 +1063,29 @@ export default function InventoryView({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5" onClick={(e) => e.stopPropagation()}> <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> {/* The ₹ was an absolutely-positioned span overlapping the
<div className="relative"> field's padding; NumberInput carries it as a real prefix
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 font-bold pointer-events-none">₹</span> so it cannot drift out of alignment. */}
<input <NumberInput
type="number" label="Selling Price (₹)"
step="0.01" value={importPrice === '' ? null : Number(importPrice)}
value={importPrice} onChange={(v) => setImportPrice(v == null ? '' : String(v))}
onChange={(e) => setImportPrice(e.target.value)} step={0.01}
onClick={(e) => e.stopPropagation()} min={0}
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>
</div> </div>
<button <Button
disabled={!importPrice || Number(importPrice) <= 0 || isPricing} 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={() => { onClick={() => {
// Publishing is a listing/pricing action, never a stock delivery — real // Publishing is a listing/pricing action, never a stock delivery — real
// quantity only lands via Mark as Received. priceEverywhere() sends // 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" />
> <p className="text-[10px] font-semibold text-center -mt-1 opacity-60">
<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">
Applies to {allLocationIds.length === 1 ? 'your store' : `all ${allLocationIds.length} stores`} Applies to {allLocationIds.length === 1 ? 'your store' : `all ${allLocationIds.length} stores`}
</p> </p>
{storeCat.has(selectedAdminProduct.id) && ( {storeCat.has(selectedAdminProduct.id) && (
<button <Button
onClick={() => storeCat.remove(selectedAdminProduct.id)} label="Remove from Store Catalogue"
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" variant="destructive"
> icon={<X size={14} strokeWidth={2} />}
<X size={14} strokeWidth={2} /> Remove from Store Catalogue onClick={() => storeCat.remove(selectedAdminProduct.id)}
</button> />
)} )}
</div> </div>
</div> </div>
@@ -1201,24 +1192,21 @@ export default function InventoryView({
{isPending ? ( {isPending ? (
<div className="flex gap-3"> <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); }} 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' }} <Button
> label="Reject"
<CheckCircle size={18} /> Approve Stock variant="destructive"
</button> icon={<X size={18} />}
<button
onClick={() => { updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Rejected'); setSelectedRequest(null); }} 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>
) : ( ) : (
<div className="flex items-center justify-center gap-2 p-3 bg-slate-50 rounded-xl border border-slate-200 shadow-sm text-center"> <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 text-slate-500"> <span className="text-sm font-bold opacity-70">
This request has been {req.pickData.status.toLowerCase()}. This request has been {req.pickData.status.toLowerCase()}.
</span> </span>
</div> </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 React, { useMemo, useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { import {
ShoppingBag, Clock, CheckCircle2, XCircle, MapPin, Phone, ShoppingBag, Clock, CheckCircle2, XCircle, MapPin, Phone,
Package, Loader2, X, Download, ChevronLeft, ChevronRight, Truck, Package, Loader2, X, Download, ChevronLeft, ChevronRight,
UserCheck, UserCheck,
} from 'lucide-react'; } from 'lucide-react';
import { import {
@@ -28,10 +27,21 @@ import {
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import { shortTime } from '../services/fiestaMappers'; import { shortTime } from '../services/fiestaMappers';
import { import {
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, SearchPill, FilterBar, TH_STYLE, GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, SearchPill, FilterBar, Card as ShellCard,
ORDER_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, ORDER_STATUS, statusColor, BRAND, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT,
tint, soft, edge, ring, tint, soft, edge,
} from './consoleUi'; } 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 { interface OrdersDeliveriesViewProps {
searchQuery?: string; searchQuery?: string;
@@ -187,10 +197,28 @@ export default function OrdersDeliveriesView({
} }
right={ right={
<div className="flex items-center gap-3"> <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"> <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> <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> </div>
<span className="inline-flex items-center gap-1.5 rounded-full font-extrabold" <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 }}> 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"> <div className="w-full lg:w-64">
<SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders (Ctrl+K)…" inputRef={searchRef} /> <SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders (Ctrl+K)…" inputRef={searchRef} />
</div> </div>
<button <Button
label="CSV"
size="sm"
icon={<Download size={13} />}
isDisabled={rows.length === 0}
onClick={exportCsv} 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>
</div> </div>
</FilterBar> </FilterBar>
{/* Table */} {/* Table */}
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}> <ShellCard className="overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 900 }}> <Table style={{ minWidth: 900 }}>
<thead> <TableHeader>
<tr> <TableRow>
{['#', 'Order', 'Branch', 'Pickup', 'Drop', 'Qty', 'COD', 'Amount', 'Status', ''].map((h, i) => ( {['#', '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> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{allOrdersQ.isLoading ? ( {allOrdersQ.isLoading ? (
<tr><td colSpan={10} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}> <TableRow><TableCell colSpan={10}>
<span className="inline-flex items-center gap-2 text-xs font-semibold"> <div className="py-8 flex justify-center"><Spinner label="Loading orders…" /></div>
<Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading orders… </TableCell></TableRow>
</span>
</td></tr>
) : pageRows.length === 0 ? ( ) : pageRows.length === 0 ? (
<tr><td colSpan={10} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}> <TableRow><TableCell colSpan={10}>
No {status} orders found for this date range or search. <EmptyState
</td></tr> isCompact
icon={<Package size={26} />}
title={`No ${status} orders found`}
description="Nothing matches this date range or search."
/>
</TableCell></TableRow>
) : ( ) : (
pageRows.map((r, i) => { pageRows.map((r, i) => {
const st = fstr(r.orderstatus).toLowerCase(); const st = fstr(r.orderstatus).toLowerCase();
const cod = fnum(r.collectionamt); const cod = fnum(r.collectionamt);
const amount = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt); const amount = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
return ( return (
<tr // Keyed on orderheaderid first. `orderid` is not unique in
key={fstr(r.orderid) || fstr(r.orderheaderid) || i} // this tenant's data — the same defect fixed in OrdersView,
className="transition-colors align-top" // which reads the same rows through the same endpoint.
style={{ borderBottom: `1px solid ${DIVIDER}` }} <TableRow key={fstr(r.orderheaderid) || fstr(r.orderid) || i}>
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} <TableCell><span className="font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</span></TableCell>
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} <TableCell>
> <p className="font-extrabold font-mono text-[13px]">{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
<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>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p> <p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
</td> </TableCell>
<td className="px-3 py-2.5"> <TableCell>
<span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}> <span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}>
<MapPin size={11} /> {fstr(r.applocation) || '—'} <MapPin size={11} /> {fstr(r.applocation) || '—'}
</span> </span>
{fstr(r.locationname) && <p className="text-[10px] truncate max-w-[120px]" style={{ color: TEXT_2 }}>{fstr(r.locationname)}</p>} {fstr(r.locationname) && <p className="text-[10px] truncate max-w-[120px]" style={{ color: TEXT_2 }}>{fstr(r.locationname)}</p>}
</td> </TableCell>
<td className="px-3 py-2.5"> <TableCell>
<p className="font-bold text-[12px] truncate max-w-[140px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p> <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> <p className="text-[10px] truncate max-w-[140px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
</td> </TableCell>
<td className="px-3 py-2.5"> <TableCell>
<p className="font-bold text-[12px] truncate max-w-[140px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p> <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> <p className="text-[10px] truncate max-w-[140px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
</td> </TableCell>
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td> <TableCell><span className="font-mono text-[12px]">{fnum(r.quantity) || '—'}</span></TableCell>
<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> <TableCell><span className="font-mono text-[12px] font-semibold" style={cod > 0 ? undefined : { color: TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'}</span></TableCell>
<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> <TableCell><span className="font-mono text-[12px] font-semibold" style={amount > 0 ? undefined : { color: TEXT_3 }}>{amount > 0 ? `₹${amount.toLocaleString('en-IN')}` : '—'}</span></TableCell>
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td> <TableCell><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></TableCell>
<td className="px-3 py-2.5 text-right"> <TableCell>
<button onClick={() => setDetailOrder(r)} <div className="text-right">
className="rounded-full font-extrabold cursor-pointer transition-colors" <Button
style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}> label="View"
View aria-label={`View order ${fstr(r.orderid) || fstr(r.orderheaderid)}`}
</button> variant="secondary"
</td> size="sm"
</tr> onClick={() => setDetailOrder(r)}
/>
</div>
</TableCell>
</TableRow>
); );
}) })
)} )}
</tbody> </TableBody>
</table> </Table>
</div> </div>
{/* Totals footer */} {/* Totals footer */}
@@ -322,16 +352,29 @@ export default function OrdersDeliveriesView({
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: TEXT_2 }}> <span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: TEXT_2 }}>
Page {pageno} · {pageRows.length} of {rows.length} shown Page {pageno} · {pageRows.length} of {rows.length} shown
</span> </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"> <div className="flex items-center gap-2">
<PagerBtn disabled={pageno === 1} onClick={() => setPageno((p) => Math.max(1, p - 1))}> <Button
<ChevronLeft size={13} /> Prev label="Prev"
</PagerBtn> variant="secondary"
<PagerBtn disabled={!hasNext} onClick={() => setPageno((p) => p + 1)}> size="sm"
Next <ChevronRight size={13} /> icon={<ChevronLeft size={13} />}
</PagerBtn> 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> </div>
</div> </ShellCard>
{detailOrder && <OrderDetailModal order={detailOrder} onClose={() => setDetailOrder(null)} />} {detailOrder && <OrderDetailModal order={detailOrder} onClose={() => setDetailOrder(null)} />}
</div> </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 ──────────────────────────────────────────────────────── // ── Order details modal ────────────────────────────────────────────────────────
function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void }) { function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void }) {
const orderheaderid = order.orderheaderid ?? order.orderid; const orderheaderid = order.orderheaderid ?? order.orderid;
@@ -387,26 +420,23 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
{ label: 'Delivered', field: 'deliverytime' }, { label: 'Delivered', field: 'deliverytime' },
]; ];
return createPortal( // PHASE 6 — the portal and the `width: min(32rem, 92vw)` are gone with the
<div // overlay. Both existed because a `fixed inset-0` resolves against the nearest
className="fixed inset-0 z-[200] flex items-center justify-center p-4" // transformed ancestor rather than the viewport, and this page has several, so
style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }} // the panel used to collapse. Dialog renders in the browser's top layer, which
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} // no ancestor can contain.
> return (
<div <Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
className="bg-white max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200" <div className="flex flex-col max-h-[90vh]">
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%)` }} />
{/* Modal header */} {/* Modal header */}
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}> <div className="p-4 flex justify-between items-center shrink-0">
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}> <Heading level={3}>
<Package size={16} style={{ color: BRAND }} /> <span className="flex items-center gap-2">
Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`} <Package size={16} style={{ color: BRAND }} />
</h4> Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button> </span>
</Heading>
<IconButton label="Close order details" variant="ghost" size="sm" icon={<X size={16} />} onClick={onClose} />
</div> </div>
{/* Body */} {/* Body */}
@@ -498,14 +528,10 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
</div> </div>
{/* Footer */} {/* Footer */}
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}> <div className="p-3 flex justify-end shrink-0">
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" <Button label="Close" onClick={onClose} />
style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}>
Close
</button>
</div> </div>
</div> </div>
</div>, </Dialog>
document.body,
); );
} }

View File

@@ -13,7 +13,7 @@
import React, { useMemo, useState, useRef, useEffect } from 'react'; import React, { useMemo, useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom'; 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 { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails, useFiestaRiders, useFiestaAssignRider, useFiestaNotifyRider } from '../services/fiestaQueries';
import { import {
FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, RIDER_MESSAGES, RiderNotReachableError, FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, RIDER_MESSAGES, RiderNotReachableError,
@@ -21,9 +21,26 @@ import {
} from '../services/fiestaApi'; } from '../services/fiestaApi';
import { shortTime } from '../services/fiestaMappers'; import { shortTime } from '../services/fiestaMappers';
import { import {
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE, KpiStrip, Pill, StatusChip, SearchPill, FilterBar, Card as ShellCard,
ORDER_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge, ring, ORDER_STATUS, statusColor, BRAND, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge, ring,
} from './consoleUi'; } 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 { interface OrdersViewProps {
searchQuery?: string; searchQuery?: string;
@@ -357,12 +374,28 @@ export default function OrdersView({
</React.Fragment> </React.Fragment>
))} ))}
</div> </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"> <div className="flex items-center gap-2 text-xs">
<input type="date" value={fromdate} max={todate} onChange={(e) => setScope({ from: e.target.value })} <DateInput
className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} /> 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> <span style={{ color: TEXT_3 }}>→</span>
<input type="date" value={todate} min={fromdate} onChange={(e) => setScope({ to: e.target.value })} <DateInput
className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} /> label="To date"
isLabelHidden
size="sm"
value={todate as ISODateString}
min={fromdate as ISODateString}
onChange={(v) => { if (v) setScope({ to: v }); }}
/>
</div> </div>
</div> </div>
</FilterBar> </FilterBar>
@@ -386,27 +419,28 @@ export default function OrdersView({
</div> </div>
<div className="flex items-center gap-2 w-full lg:w-auto lg:shrink-0"> <div className="flex items-center gap-2 w-full lg:w-auto lg:shrink-0">
{branches.length > 1 && ( {branches.length > 1 && (
<select <div className="shrink-0 min-w-[150px]">
value={branch} <Selector
onChange={(e) => { setBranch(Number(e.target.value)); setPageno(1); }} label="Filter by branch"
title="Filter by branch / app-location" isLabelHidden
className="rounded-full font-bold text-xs outline-none cursor-pointer shrink-0" size="sm"
style={{ padding: '7px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }} value={String(branch)}
> onChange={(v) => { setBranch(Number(v) || 0); setPageno(1); }}
<option value={0}>All branches</option> options={[
{branches.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)} { value: '0', label: 'All branches' },
</select> ...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> <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} 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>
</div> </div>
</FilterBar> </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 }}> <span className="inline-flex items-center gap-1.5 font-extrabold text-xs" style={{ color: BRAND }}>
<UserCheck size={15} /> {selected.size} selected <UserCheck size={15} /> {selected.size} selected
</span> </span>
<div className="flex bg-white rounded-full p-0.5 ml-2 border" style={{ borderColor: edge(BRAND) }}> {/* PHASE 6 — the two-button rider-source toggle is a SegmentedControl.
<button It was a pair of buttons that looked like a segmented control but
onClick={() => setRiderSource('all')} announced as two unrelated buttons with no indication which was
title="Every rider on duty at this branch right now" active; the real one exposes the selection to assistive tech. */}
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors ${riderSource === 'all' ? '' : 'text-slate-500 hover:bg-slate-50'}`} <div className="ml-2">
style={riderSource === 'all' ? { background: tint(BRAND), color: BRAND } : undefined} <SegmentedControl
label="Rider source"
size="sm"
value={riderSource}
onChange={(v) => setRiderSource(v as 'all' | 'partner')}
> >
On Duty <SegmentedControlItem value="all" label="On Duty" />
</button> <SegmentedControlItem value="partner" label="This Partner" isDisabled={!orderPartnerId} />
<button </SegmentedControl>
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>
</div> </div>
<select {/* The empty-list wording carried real information — which of the two
value={assignRiderId} scopes came back empty — so it moves to `placeholder` rather than
onChange={(e) => setAssignRiderId(Number(e.target.value))} being flattened into a generic "Select…". */}
disabled={selected.size === 0} <div className="min-w-[200px] max-w-[260px]">
title="Choose a rider to assign" <Selector
className="rounded-full font-bold text-xs outline-none cursor-pointer disabled:opacity-50" label="Choose a rider to assign"
style={{ padding: '7px 12px', border: `1.5px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 260 }} isLabelHidden
> size="sm"
<option value={0}> value={assignRiderId ? String(assignRiderId) : ''}
{ridersQ.isLoading onChange={(v) => setAssignRiderId(Number(v) || 0)}
? 'Loading riders…' isDisabled={selected.size === 0}
: riderOptions.length isLoading={ridersQ.isLoading}
? 'Select rider…' placeholder={
: riderSource === 'partner' ridersQ.isLoading
? 'No riders on duty for this partner' ? 'Loading riders…'
: 'No riders on duty right now'} : riderOptions.length
</option> ? 'Select rider…'
{riderOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)} : riderSource === 'partner'
</select> ? 'No riders on duty for this partner'
<button : '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} 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 && ( {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 }}> <Button label="Clear" variant="secondary" size="sm" onClick={() => setSelected(new Set())} />
Clear
</button>
)} )}
{assignMsg && <span className="text-[11px] font-semibold ml-auto" style={{ color: TEXT_2 }}>{assignMsg}</span>} {assignMsg && <span className="text-[11px] font-semibold ml-auto" style={{ color: TEXT_2 }}>{assignMsg}</span>}
</div> </div>
)} )}
{/* Table */} {/* Table
<div className="bg-white border overflow-hidden" style={{ borderColor: BORDER }}> 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"> <div className="overflow-x-auto">
<table className="w-full" style={{ minWidth: 960 }}> <Table style={{ minWidth: 960 }}>
<thead> <TableHeader>
<tr> <TableRow>
<th className="px-3 py-2.5 text-left" style={TH_STYLE}> <TableHeaderCell>
<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 }} /> <CheckboxInput
</th> 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) => ( {['#', '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> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{ordersQ.isLoading ? ( {ordersQ.isLoading ? (
<tr><td colSpan={12} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}> <TableRow><TableCell colSpan={12}>
<span className="inline-flex items-center gap-2 text-xs font-semibold"><Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading orders…</span> <div className="py-8 flex justify-center"><Spinner label="Loading orders…" /></div>
</td></tr> </TableCell></TableRow>
) : rows.length === 0 ? ( ) : 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) => { rows.map((r, i) => {
const st = fstr(r.orderstatus).toLowerCase(); const st = fstr(r.orderstatus).toLowerCase();
const cod = fnum(r.collectionamt); const cod = fnum(r.collectionamt);
const charges = fnum(r.deliverycharge) || fnum(r.deliverycharges); const charges = fnum(r.deliverycharge) || fnum(r.deliverycharges);
const isSel = selected.has(rowKey(r));
return ( return (
<tr key={fstr(r.orderid) || i} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER_C}`, background: selected.has(rowKey(r)) ? tint(BRAND) : 'transparent' }} // `rowKey`, not `orderid`. `orderid` is NOT unique — this
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'; }}> // tenant returns several rows sharing one (three "1135-1" on
<td className="px-3 py-2.5"> // 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' ? ( {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> </TableCell>
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td> <TableCell><span className="font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</span></TableCell>
<td className="px-3 py-2.5"> <TableCell>
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p> <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> <p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
</td> </TableCell>
<td className="px-3 py-2.5"> <TableCell>
<span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}> <span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}>
<MapPin size={11} /> {fstr(r.applocation) || '—'} <MapPin size={11} /> {fstr(r.applocation) || '—'}
</span> </span>
{fstr(r.locationname) && <p className="text-[10px] truncate max-w-[130px]" style={{ color: TEXT_2 }}>{fstr(r.locationname)}</p>} {fstr(r.locationname) && <p className="text-[10px] truncate max-w-[130px]" style={{ color: TEXT_2 }}>{fstr(r.locationname)}</p>}
</td> </TableCell>
<td className="px-3 py-2.5"> <TableCell>
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p> <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> <p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
</td> </TableCell>
<td className="px-3 py-2.5"> <TableCell>
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p> <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> <p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
</td> </TableCell>
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td> <TableCell><span className="font-mono text-[12px]">{fnum(r.quantity) || '—'}</span></TableCell>
<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> <TableCell><span className="font-mono text-[12px] font-semibold" style={cod > 0 ? undefined : { color: TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'}</span></TableCell>
<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> <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>
<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> <TableCell><span className="font-mono text-[12px] font-semibold" style={charges > 0 ? undefined : { color: TEXT_3 }}>{charges > 0 ? `₹${charges.toLocaleString('en-IN')}` : '—'}</span></TableCell>
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td> <TableCell><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></TableCell>
<td className="px-3 py-2.5 text-right"> <TableCell>
<button onClick={() => setDetailOrder(r)} className="rounded-full font-extrabold cursor-pointer transition-colors" <div className="text-right">
style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>View</button> <Button label="View" variant="secondary" size="sm" onClick={() => setDetailOrder(r)} />
</td> </div>
</tr> </TableCell>
</TableRow>
); );
}) })
)} )}
</tbody> </TableBody>
</table> </Table>
</div> </div>
{/* Totals across the filtered rows */} {/* Totals across the filtered rows */}
{rows.length > 0 && ( {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 }}> <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> <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"> <div className="flex items-center gap-2">
<PagerBtn disabled={pageno === 1} onClick={() => setPageno((p) => Math.max(1, p - 1))}><ChevronLeft size={13} /> Prev</PagerBtn> <Button
<PagerBtn disabled={!hasNext} onClick={() => setPageno((p) => p + 1)}>Next <ChevronRight size={13} /></PagerBtn> 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> </div>
</div> </ShellCard>
{detailOrder && <OrderDetailModal order={detailOrder} onClose={() => setDetailOrder(null)} />} {detailOrder && <OrderDetailModal order={detailOrder} onClose={() => setDetailOrder(null)} />}
@@ -568,38 +659,37 @@ export default function OrdersView({
Opens the full-page review/assign view on click. */} Opens the full-page review/assign view on click. */}
{status === 'created' && selected.size > 1 && !showSelected && {status === 'created' && selected.size > 1 && !showSelected &&
createPortal( createPortal(
<button // PHASE 6 — the label used to be collapsed to `max-w-0` and revealed
onClick={() => setShowSelected(true)} // only on hover, so the button read as an unlabelled icon until you
title={`Review & assign ${selected.size} selected order${selected.size === 1 ? '' : 's'}`} // pointed at it and was invisible to anyone not using a mouse. The
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" // Astryx Button carries the count in its label instead.
style={{ top: '70%', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, borderTopLeftRadius: 9999, borderBottomLeftRadius: 9999, boxShadow: `0 10px 30px ${ring(BRAND)}` }} <div className="fixed right-4 z-[150] animate-in slide-in-from-right-4" style={{ top: '70%' }}>
> <Button
<span className="relative inline-flex"> label={`Review ${selected.size} selected`}
<ClipboardList size={18} /> icon={<ClipboardList size={16} />}
<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> onClick={() => setShowSelected(true)}
</span> />
<span className="max-w-0 overflow-hidden group-hover:max-w-[80px] transition-all duration-200 whitespace-nowrap">Review</span> </div>,
</button>,
document.body, document.body,
)} )}
{showSelected && {/* No createPortal any more — Dialog renders in the browser's top layer,
createPortal( which is already outside every ancestor's containing block. */}
<SelectedOrdersPage {showSelected && (
rows={selectedRows} <SelectedOrdersPage
rowKey={rowKey} rows={selectedRows}
riderOptions={riderOptions} rowKey={rowKey}
ridersLoading={ridersQ.isLoading} riderOptions={riderOptions}
assignRiderId={assignRiderId} ridersLoading={ridersQ.isLoading}
setAssignRiderId={setAssignRiderId} assignRiderId={assignRiderId}
assigning={assignMut.isPending} setAssignRiderId={setAssignRiderId}
assignMsg={assignMsg} assigning={assignMut.isPending}
onAssign={handleAssign} assignMsg={assignMsg}
onRemove={(k) => toggleRow(k)} onAssign={handleAssign}
onClose={() => setShowSelected(false)} onRemove={(k) => toggleRow(k)}
/>, onClose={() => setShowSelected(false)}
document.body, />
)} )}
</div> </div>
); );
} }
@@ -623,75 +713,114 @@ function SelectedOrdersPage({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<div className="fixed inset-0 z-[200] overflow-y-auto animate-in fade-in duration-200" style={{ background: '#f8fafc' }}> // PHASE 6 — was a `fixed inset-0` page takeover with no Escape and no focus
{/* Sticky page header with the assign controls */} // trap: tabbing from it walked straight back into the orders board
<div className="sticky top-0 z-10 border-b" style={{ background: '#fff', borderColor: BORDER }}> // underneath. Dialog's fullscreen variant gives both, and keeps the same
<div className="max-w-5xl mx-auto px-4 md:px-8 py-4 flex flex-wrap items-center gap-3"> // full-bleed shape.
<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 // What it does NOT give is focus restore — verified in Chrome: closing this
</button> // leaves focus on <body>, not on the control that opened it. consoleUi's
<div className="flex items-center gap-2"> // SlideDrawer restores it because it does so by hand. That gap is Astryx's,
<span className="h-9 w-9 rounded-xl flex items-center justify-center" style={{ background: tint(BRAND), color: BRAND }}><ClipboardList size={18} /></span> // it applies to every Dialog in the app, and it is a missing gain rather
<div> // than a regression (the old overlay managed no focus at all), so it is not
<h1 className="font-bold text-lg tracking-tight leading-none" style={{ color: TEXT }}>Selected Orders</h1> // patched here one screen at a time.
<p className="text-[11px] mt-1" style={{ color: TEXT_2 }}>{rows.length} order{rows.length === 1 ? '' : 's'} ready to assign</p> <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>
<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>
</div>
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6"> <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>} {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 ? ( {rows.length === 0 ? (
<div className="bg-white border rounded-2xl p-12 text-center text-xs" style={{ borderColor: BORDER, color: TEXT_3 }}> <ShellCard className="p-12">
No orders selected. <button onClick={onClose} className="font-bold underline cursor-pointer" style={{ color: BRAND }}>Go back</button> <EmptyState
</div> title="No orders selected"
) : ( actions={<Button label="Go back" variant="secondary" onClick={onClose} />}
<div className="bg-white border overflow-hidden" style={{ borderColor: BORDER }}> />
<div className="overflow-x-auto"> </ShellCard>
<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> <ShellCard className="overflow-hidden">
<tbody> <div className="overflow-x-auto">
{rows.map((r, i) => { <Table style={{ minWidth: 720 }}>
const st = fstr(r.orderstatus).toLowerCase(); <TableHeader>
return ( <TableRow>
<tr key={rowKey(r) || i} style={{ borderBottom: `1px solid ${DIVIDER_C}` }}> {['#', 'Order', 'Pickup', 'Drop', 'Status', ''].map((h, i) => <TableHeaderCell key={i}>{h}</TableHeaderCell>)}
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td> </TableRow>
<td className="px-3 py-2.5"> </TableHeader>
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p> <TableBody>
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p> {rows.map((r, i) => {
</td> const st = fstr(r.orderstatus).toLowerCase();
<td className="px-3 py-2.5"> return (
<p className="font-bold text-[12px] truncate max-w-[180px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p> <TableRow key={rowKey(r) || i}>
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p> <TableCell><span className="font-mono" style={{ color: TEXT_3 }}>{i + 1}</span></TableCell>
</td> <TableCell>
<td className="px-3 py-2.5"> <p className="font-extrabold font-mono text-[13px]">{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
<p className="font-bold text-[12px] truncate max-w-[180px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p> <p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p> </TableCell>
</td> <TableCell>
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td> <p className="font-bold text-[12px] truncate max-w-[180px]">{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
<td className="px-3 py-2.5 text-right"> <p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
<button onClick={() => onRemove(rowKey(r))} title="Remove from selection" className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={15} /></button> </TableCell>
</td> <TableCell>
</tr> <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>
</tbody> <TableCell><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></TableCell>
</table> <TableCell>
</div> <div className="text-right">
</div> <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>
</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 ───────────────────────────────────────────────────────── // ── Order details modal ─────────────────────────────────────────────────────────
function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void }) { function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void }) {
const orderheaderid = order.orderheaderid ?? order.orderid; 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 st = fstr(order.orderstatus).toLowerCase();
const total = fnum(order.deliveryamt) || fnum(order.orderamount); const total = fnum(order.deliveryamt) || fnum(order.orderamount);
// Portal to <body> so the overlay escapes any transformed / blurred / overflow // PHASE 6 — the hand-rolled overlay is gone, and with it the portal and the
// ancestor in the view tree — otherwise `fixed inset-0` resolves against that // `width: min(32rem, 92vw)` workaround. Both existed for one reason: a
// ancestor (not the viewport) and the panel collapses to a sliver. The explicit // `fixed inset-0` resolves against the nearest transformed/filtered ancestor
// viewport-relative width is a belt-and-suspenders so sizing never depends on // rather than the viewport, and this page has several, so the panel used to
// percentage resolution against a broken containing block. // collapse to a sliver. Dialog renders in the browser's top layer, which no
return createPortal( // ancestor can contain — so the escape hatches are no longer needed.
<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)' }} return (
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}> <Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
<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 className="flex flex-col max-h-[90vh]">
<div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${soft(BRAND)} 100%)` }} /> <div className="p-4 flex justify-between items-center shrink-0">
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}> <Heading level={3}>
<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> <span className="flex items-center gap-2">
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button> <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>
<div className="p-4 space-y-4 overflow-y-auto flex-1"> <div className="p-4 space-y-4 overflow-y-auto flex-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /> <StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} />
<span className="text-[11px] font-medium" style={{ color: TEXT_2 }}>{shortTime(order.orderdate || order.deliverydate)}</span> <span className="text-[11px] font-medium" style={{ color: TEXT_2 }}>{shortTime(order.orderdate || order.deliverydate)}</span>
</div> </div>
<div className="p-3 rounded-xl space-y-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}> <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" style={{ color: TEXT }}>{fstr(order.deliverycustomer) || 'Customer'}</div> <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>} {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 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>
<div> <div>
<span className="text-[10px] font-extrabold uppercase tracking-wide block mb-2" style={{ color: TEXT_2 }}>Order Items</span> <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}` }}> <div className="rounded-xl p-3" style={{ background: 'rgba(127,127,127,0.04)' }}>
{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>} {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>} {!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) => ( {lines.map((item, idx) => (
<div key={idx} className="py-2 flex justify-between items-center" style={{ borderTop: idx ? `1px solid ${DIVIDER_C}` : undefined }}> <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> <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" style={{ color: TEXT }}>₹{item.lineTotal.toLocaleString('en-IN')}</span> <span className="font-extrabold font-mono text-xs">₹{item.lineTotal.toLocaleString('en-IN')}</span>
</div> </div>
))} ))}
{total > 0 && ( {total > 0 && (
@@ -769,13 +891,10 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
</div> </div>
</div> </div>
</div> </div>
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}> <div className="p-3 flex justify-end shrink-0">
<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> <Button label="Close" onClick={onClose} />
</div> </div>
</div> </div>
</div>, </Dialog>
document.body,
); );
} }
const BRAND_LIGHT_LOCAL = '#9255AB';

View File

@@ -26,6 +26,13 @@ import { Selector } from '@astryxdesign/core/Selector';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { TimeInput } from '@astryxdesign/core/TimeInput'; 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 { import {
AlertTriangle, AlertTriangle,
Check, Check,
@@ -60,6 +67,7 @@ import {
BORDER, BORDER,
BRAND, BRAND,
Card, Card,
Card as ShellCard,
DIVIDER, DIVIDER,
Pill, Pill,
StatusChip, StatusChip,
@@ -67,7 +75,6 @@ import {
TEXT, TEXT,
TEXT_2, TEXT_2,
TEXT_3, TEXT_3,
TH_STYLE,
edge, edge,
tint, tint,
} from './consoleUi'; } from './consoleUi';
@@ -443,89 +450,99 @@ export default function PosStaffPanel({
</div> </div>
</Card> </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"> <div className="overflow-x-auto">
<table className="w-full text-sm whitespace-nowrap" style={{ minWidth: 940 }}> {/* whiteSpace: the old <table> carried `whitespace-nowrap`; Astryx's
<thead style={TH_STYLE}> Table does not, and without it the till username breaks
<tr> mid-token ("supervisor.1169@po / s.nearle.in"). The wrapper
<th className="px-4 py-3 text-left">Name</th> above already scrolls horizontally. */}
<th className="px-4 py-3 text-left">Role</th> <Table style={{ minWidth: 940, whiteSpace: 'nowrap' }}>
<th className="px-4 py-3 text-left">Mobile</th> <TableHeader>
<th className="px-4 py-3 text-left">Shift</th> <TableRow>
<th className="px-4 py-3 text-left">Username</th> <TableHeaderCell>Name</TableHeaderCell>
<th className="px-4 py-3 text-left">PIN</th> <TableHeaderCell>Role</TableHeaderCell>
<th className="px-4 py-3 text-left">Password</th> <TableHeaderCell>Mobile</TableHeaderCell>
<th className="px-4 py-3 text-left">Status</th> <TableHeaderCell>Shift</TableHeaderCell>
<th className="px-4 py-3 text-right">&nbsp;</th> <TableHeaderCell>Username</TableHeaderCell>
</tr> <TableHeaderCell>PIN</TableHeaderCell>
</thead> <TableHeaderCell>Password</TableHeaderCell>
<tbody className="divide-y" style={{ borderColor: DIVIDER }}> <TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell><span className="sr-only">Row actions</span></TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{staff.map((s) => { {staff.map((s) => {
const inactive = (s.status || '').toLowerCase() === 'inactive'; const inactive = (s.status || '').toLowerCase() === 'inactive';
const who = s.full_name || `#${s.user_id}`;
return ( return (
<tr key={s.user_id} className="hover:bg-slate-50/70" style={{ opacity: inactive ? 0.55 : 1 }}> <TableRow key={s.user_id} style={{ opacity: inactive ? 0.55 : 1 }}>
<td className="px-4 py-3 font-bold" style={{ color: TEXT }}>{s.full_name || `#${s.user_id}`}</td> <TableCell><span className="font-bold">{who}</span></TableCell>
<td className="px-4 py-3"> <TableCell>
<StatusChip <StatusChip
label={s.role || String(s.role_id)} label={s.role || String(s.role_id)}
color={s.role_id === POS_ROLE_CASHIER ? BRAND : '#0ea5e9'} color={s.role_id === POS_ROLE_CASHIER ? BRAND : '#0ea5e9'}
/> />
</td> </TableCell>
<td className="px-4 py-3 font-mono text-[12px]" style={{ color: s.contactno ? TEXT : '#f59e0b' }}> <TableCell>
{s.contactno || 'not set'} <span className="font-mono text-[12px]" style={s.contactno ? undefined : { color: '#f59e0b' }}>
</td> {s.contactno || 'not set'}
<td className="px-4 py-3 text-[12px]" style={{ color: TEXT_2 }}> </span>
{s.shift_name ? `${s.shift_name} · ${s.shift_start}–${s.shift_end}` : '—'} </TableCell>
</td> <TableCell>
<td className="px-4 py-3 font-mono text-[12px]" style={{ color: TEXT_2 }}>{s.authname || '—'}</td> <span className="text-[12px]" style={{ color: TEXT_2 }}>
<td className="px-4 py-3 font-mono tracking-widest" style={{ color: TEXT_2 }}>{s.pin || '—'}</td> {s.shift_name ? `${s.shift_name} · ${s.shift_start}–${s.shift_end}` : '—'}
<td className="px-4 py-3"> </span>
{s.has_password ? ( </TableCell>
<span className="inline-flex items-center gap-1 text-[11.5px] font-semibold" style={{ color: '#10b981' }}> <TableCell><span className="font-mono text-[12px]" style={{ color: TEXT_2 }}>{s.authname || '—'}</span></TableCell>
<ShieldCheck size={12} /> Set <TableCell><span className="font-mono tracking-widest" style={{ color: TEXT_2 }}>{s.pin || '—'}</span></TableCell>
</span> <TableCell>
) : ( {/* Was two hand-tinted spans. "Set"/"Not set" is a state,
<span className="inline-flex items-center gap-1 text-[11.5px] font-semibold" style={{ color: '#f59e0b' }}> so it reads as success vs warning. */}
<AlertTriangle size={12} /> Not set {s.has_password
</span> ? <Badge variant="success" label="Set" icon={<ShieldCheck size={12} />} />
)} : <Badge variant="warning" label="Not set" icon={<AlertTriangle size={12} />} />}
</td> </TableCell>
<td className="px-4 py-3"> <TableCell>
<StatusChip label={inactive ? 'Inactive' : 'Active'} color={inactive ? TEXT_3 : '#10b981'} /> <StatusChip label={inactive ? 'Inactive' : 'Active'} color={inactive ? TEXT_3 : '#10b981'} />
</td> </TableCell>
<td className="px-4 py-3"> <TableCell>
<div className="flex items-center justify-end gap-1.5"> <div className="flex items-center justify-end gap-1.5">
{inactive ? ( {inactive ? (
<button <Button
label="Reactivate"
aria-label={`Reactivate ${who}`}
variant="secondary"
size="sm"
isDisabled={updateMut.isPending}
onClick={() => reactivate(s)} 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} /> <Pencil size={13} />
</IconBtn> </IconBtn>
<IconBtn title="Reset password" color="#0ea5e9" onClick={() => setConfirmReset(s)}> <IconBtn title={`Reset password for ${who}`} color="#0ea5e9" onClick={() => setConfirmReset(s)}>
<KeyRound size={13} /> <KeyRound size={13} />
</IconBtn> </IconBtn>
<IconBtn title="Deactivate" color="#ef4444" onClick={() => setConfirmOff(s)}> <IconBtn title={`Deactivate ${who}`} color="#ef4444" onClick={() => setConfirmOff(s)}>
<Trash2 size={13} /> <Trash2 size={13} />
</IconBtn> </IconBtn>
</> </>
)} )}
</div> </div>
</td> </TableCell>
</tr> </TableRow>
); );
})} })}
</tbody> </TableBody>
</table> </Table>
</div> </div>
</div> </ShellCard>
)} )}
{rowError && ( {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. anyone signed in at a counter with it will be refused on their next sign-in.
</p> </p>
<div className="flex flex-wrap items-center gap-2 mt-4"> <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)} 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" <Button label="Cancel" variant="secondary" onClick={() => setConfirmReset(null)} />
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>
</div> </div>
</Modal> </Modal>
)} )}
{/* One-time credentials. The password is not recoverable after this. */} {/* One-time credentials. The password is not recoverable after this. */}
{issued && ( {issued && (
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4"> // PHASE 6 — the last hand-rolled overlay on this page, and the one that
<div className="absolute inset-0 bg-slate-900/45" onClick={() => setIssued(null)} /> // mattered most: it shows a password that exists nowhere else. It now
<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%)' }}> // goes through the same Modal as edit / reset / deactivate, so it gains
<div className="flex items-center gap-2 mb-1"> // Escape and a focus trap like the rest.
<KeyRound size={16} style={{ color: BRAND }} /> <Modal onClose={() => setIssued(null)} title="Account created">
<h3 className="text-[16px] font-extrabold" style={{ color: TEXT }}>Account created</h3> <div>
</div> <Banner status="warning" title="Copy this password now">
<p className="text-[12.5px] mb-4" style={{ color: '#b45309' }}> It is shown once and cannot be looked up again.
This password is shown once and cannot be looked up again. Copy it now. </Banner>
</p>
<div className="rounded-xl p-3 space-y-2" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}> <div className="rounded-xl p-3 space-y-2" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
<Field label="Name" value={issued.full_name} /> <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"> <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} 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 }} <Button
> label={copied ? 'Done' : 'Close without copying'}
{copied ? <Check size={13} /> : <Copy size={13} />} variant="secondary"
{copied ? 'Copied' : 'Copy all'}
</button>
<button
onClick={() => setIssued(null)} 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> </div>
</div> </Modal>
)} )}
{/* Uses the same Modal as edit and reset. It was a hand-rolled copy of {/* 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. deleted, because past bills carry their name.
</p> </p>
<div className="flex flex-wrap items-center gap-2"> <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={() => onClick={() =>
deactivateMut.mutate( deactivateMut.mutate(
{ tenantid: tenantId, locationid, userid: confirmOff.user_id }, { tenantid: tenantId, locationid, userid: confirmOff.user_id },
{ onSettled: () => setConfirmOff(null) }, { 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" <Button label="Cancel" variant="secondary" onClick={() => setConfirmOff(null)} />
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>
</div> </div>
</Modal> </Modal>
)} )}
@@ -702,6 +698,10 @@ export default function PosStaffPanel({
} }
const WEEKDAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; 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. * The shift windows an outlet runs.
@@ -827,20 +827,15 @@ function ShiftManager({
Days Days
</span> </span>
{WEEKDAY_LABELS.map((d, i) => ( {WEEKDAY_LABELS.map((d, i) => (
<button <ToggleButton
key={i} key={i}
onClick={() => toggleDay(i)} label={WEEKDAY_NAMES[i]}
className="rounded-full text-[11px] font-extrabold cursor-pointer" isIconOnly
style={{ icon={<span className="text-[11px] font-extrabold">{d}</span>}
width: 26, size="sm"
height: 26, isPressed={days[i]}
background: days[i] ? BRAND : '#fff', onPressedChange={() => toggleDay(i)}
color: days[i] ? '#fff' : TEXT_3, />
border: `1px solid ${days[i] ? BRAND : BORDER}`,
}}
>
{d}
</button>
))} ))}
</div> </div>
@@ -851,21 +846,13 @@ function ShiftManager({
{error && <p className="text-[12px] mt-2 font-semibold" style={{ color: '#ef4444' }}>{error}</p>} {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"> <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} 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" <Button label="Cancel" variant="secondary" onClick={() => { setOpen(false); setError(''); }} />
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>
</div> </div>
</div> </div>
)} )}
@@ -893,7 +880,12 @@ function ShiftManager({
: WEEKDAY_LABELS.filter((_, i) => s.weekdays?.[i] === '1').join(' ') || '—'} : WEEKDAY_LABELS.filter((_, i) => s.weekdays?.[i] === '1').join(' ') || '—'}
</div> </div>
</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={() => onClick={() =>
updateMut.mutate({ updateMut.mutate({
tenantid: tenantId, tenantid: tenantId,
@@ -902,16 +894,7 @@ function ShiftManager({
status: inactive ? 'Active' : 'Inactive', 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> </div>
); );
})} })}
@@ -941,15 +924,20 @@ function IconBtn({
onClick: () => void; onClick: () => void;
children: React.ReactNode; 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 ( return (
<button <IconButton
label={title}
variant="secondary"
size="sm"
icon={children}
onClick={onClick} onClick={onClick}
title={title} style={{ color }}
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>
); );
} }

View File

@@ -1,9 +1,20 @@
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; 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 { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
import { useNotify } from './Toasts'; import { useNotify } from './Toasts';
import { Dialog } from '@astryxdesign/core/Dialog'; 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. * 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; } .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 { 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); } .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; } } @keyframes scan-pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
.scan-pulse { animation: scan-pulse 1.8s ease-in-out infinite; } .scan-pulse { animation: scan-pulse 1.8s ease-in-out infinite; }
`}</style> `}</style>
@@ -427,43 +446,34 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
</span> </span>
)} )}
</div> </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"> <div className="flex items-center gap-1.5">
<button <Button label="Browse" variant="secondary" size="sm" icon={<Search className="w-3.5 h-3.5" />} onClick={() => setShowManualSearch(true)} />
onClick={() => setShowManualSearch(true)} <Button
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'}`} label="Refund"
> variant={isRefundMode ? 'destructive' : 'secondary'}
<Search className="w-3.5 h-3.5" /> Browse size="sm"
</button> icon={<RefreshCcw className="w-3.5 h-3.5" />}
<button
onClick={() => setIsRefundMode(!isRefundMode)} 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'}`} />
> <Button
<RefreshCcw className="w-3.5 h-3.5" /> Refund label="Park"
</button> variant="secondary"
<button size="sm"
icon={<Save className="w-3.5 h-3.5" />}
isDisabled={cart.length === 0}
onClick={parkCart} 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'}`} {/* The parked count was a floating dot pinned to the button corner,
> outside its label — nothing announced it. */}
<Save className="w-3.5 h-3.5" /> Park <div className="flex items-center gap-1">
</button> <Button label="Retrieve" variant="secondary" size="sm" icon={<Download className="w-3.5 h-3.5" />} onClick={() => setShowParkedPrompt(true)} />
<button {parkedCarts.length > 0 && <Badge variant="warning" label={String(parkedCarts.length)} />}
onClick={() => setShowParkedPrompt(true)} </div>
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'}`} <Button label="Report" variant="secondary" size="sm" icon={<PieChart className="w-3.5 h-3.5" />} onClick={() => { checkDailyReset(); setShowReport(true); }} />
>
<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>
</div> </div>
</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'}`}> <div className={`scan-pulse ${isRefundMode ? 'text-red-400' : 'text-emerald-500'}`}>
<Barcode className="w-7 h-7" /> <Barcode className="w-7 h-7" />
</div> </div>
<input {/* The scan field keeps its oversized type deliberately — an
ref={searchInputRef} operator aims a scanner at this from arm's length and the size
type="text" is the affordance, so `pos-scan-field` restores the 20px bold
placeholder={isRefundMode ? 'Scan item to refund…' : 'Scan barcode or type SKU…'} face that TextInput's `lg` does not reach. What it gains is a
value={search} real label (it had none at all) and a clear button that is now
onChange={(e) => setSearch(e.target.value)} part of the control rather than a bare <button> beside it. */}
onKeyDown={handleSearchKeyDown} <div className="flex-1 pos-scan-field">
className="flex-1 bg-transparent border-none outline-none font-bold text-xl text-slate-800 placeholder:font-normal placeholder:text-slate-400" <TextInput
autoFocus ref={searchInputRef}
/> label={isRefundMode ? 'Scan item to refund' : 'Scan barcode or type SKU'}
{search && ( isLabelHidden
<button onClick={() => setSearch('')} className="text-slate-400 hover:text-slate-600"> size="lg"
<X className="w-4 h-4" /> placeholder={isRefundMode ? 'Scan item to refund…' : 'Scan barcode or type SKU…'}
</button> value={search}
)} onChange={(v) => setSearch(v)}
onKeyDown={handleSearchKeyDown}
hasClear
hasAutoFocus
/>
</div>
{!search && ( {!search && (
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-widest hidden md:block"> <span className="text-[11px] font-bold text-slate-400 uppercase tracking-widest hidden md:block">
Press Enter Press Enter
@@ -499,95 +514,78 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
{/* Cart Table */} {/* Cart Table */}
<div className="flex-1 overflow-y-auto bg-white"> <div className="flex-1 overflow-y-auto bg-white">
{stockQ.isLoading ? ( {stockQ.isLoading ? (
<div className="h-full flex flex-col items-center justify-center gap-3 text-slate-400"> <div className="h-full flex items-center justify-center">
<div className="w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" /> <Spinner size="xl" label="Initializing inventory…" />
<p className="text-sm font-semibold">Initializing inventory…</p>
</div> </div>
) : cart.length === 0 ? ( ) : cart.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center gap-5 text-slate-300 select-none"> <div className="h-full flex items-center justify-center">
<div className="w-24 h-24 rounded-3xl bg-slate-100 flex items-center justify-center"> <EmptyState
<ShoppingCart className="w-12 h-12 text-slate-300" /> icon={<ShoppingCart className="w-12 h-12" />}
</div> title="Cart is empty"
<div className="text-center"> description="Scan a barcode or browse products to add items"
<p className="font-bold text-slate-400 text-lg">Cart is empty</p> actions={<Button label="Browse Products" icon={<Search className="w-4 h-4" />} onClick={() => setShowManualSearch(true)} />}
<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> </div>
) : ( ) : (
<div className="w-full overflow-x-auto custom-scrollbar"> <div className="w-full overflow-x-auto custom-scrollbar">
<table className="w-full text-left border-collapse min-w-[500px]"> <Table style={{ minWidth: 500 }}>
<thead className="sticky top-0 z-10 bg-slate-50 border-b border-slate-200"> <TableHeader>
<tr> <TableRow>
<th className="px-5 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500">Product</th> <TableHeaderCell>Product</TableHeaderCell>
<th className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-center">Qty</th> <TableHeaderCell>Qty</TableHeaderCell>
<th className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-right">Rate</th> <TableHeaderCell>Rate</TableHeaderCell>
<th className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-right">Disc.</th> <TableHeaderCell>Disc.</TableHeaderCell>
<th className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-right">Amount</th> <TableHeaderCell>Amount</TableHeaderCell>
<th className="px-5 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-center w-20"></th> <TableHeaderCell><span className="sr-only">Row actions</span></TableHeaderCell>
</tr> </TableRow>
</thead> </TableHeader>
<tbody> <TableBody>
{cartWithPromos.map((item, idx) => ( {cartWithPromos.map((item, idx) => (
<tr <TableRow
key={item.id} 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="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"> <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> <span className="text-[10px] font-black text-slate-500">{idx + 1}</span>
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{item.isRefund && ( {item.isRefund && <Badge variant="error" label="Refund" />}
<span className="px-1.5 py-0.5 bg-red-100 text-red-600 text-[9px] uppercase font-black rounded tracking-wider"> <span className="font-bold text-[14px] leading-snug">{item.name}</span>
Refund
</span>
)}
<span className="font-bold text-slate-900 text-[14px] leading-snug">{item.name}</span>
</div> </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>
</div> </div>
</td> </TableCell>
<td className="px-4 py-3.5 text-center"> <TableCell>
<div className="inline-flex items-center bg-slate-100 rounded-lg overflow-hidden border border-slate-200"> {/* The −/+ pair plus a bare number box is one NumberInput
<button now: it brings its own steppers, so the two icon-only
onClick={() => updateCartQty(item.id, item.qty - 1)} buttons (neither of which had an accessible name) are
className="w-8 h-8 flex items-center justify-center text-slate-500 hover:bg-red-50 hover:text-red-500 transition-colors" gone, and `max` finally expresses the stock ceiling that
> only the + button used to enforce. */}
<Minus className="w-3.5 h-3.5" /> <div className="flex items-center gap-1 w-28">
</button> <NumberInput
<div className="flex items-center gap-0.5 px-1"> label={`Quantity for ${item.name}`}
<input isLabelHidden
type="number" size="sm"
step={item.unit === 'kg' ? '0.001' : '1'} value={item.qty}
value={item.qty} onChange={(v) => updateCartQty(item.id, v ?? 0)}
onChange={(e) => updateCartQty(item.id, parseFloat(e.target.value) || 0)} step={item.unit === 'kg' ? 0.001 : 1}
className="w-12 text-center text-sm font-bold text-slate-900 bg-transparent border-none outline-none hide-spin-button" min={0}
/> max={item.maxQty}
<span className="text-[10px] font-bold text-slate-400">{item.unit}</span> />
</div> <span className="text-[10px] font-bold shrink-0 opacity-50">{item.unit}</span>
<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>
</div> </div>
</td> </TableCell>
<td className="px-4 py-3.5 text-right"> <TableCell>
<span className="font-bold text-slate-700 text-sm">₹{item.price.toFixed(2)}</span> <div className="text-right">
<span className="block text-[10px] text-slate-400">/{item.unit}</span> <span className="font-bold text-sm">₹{item.price.toFixed(2)}</span>
</td> <span className="block text-[10px] opacity-50">/{item.unit}</span>
<td className="px-4 py-3.5 text-right"> </div>
</TableCell>
<TableCell>
{item.discountPerItem > 0 || item.hasBogo ? ( {item.discountPerItem > 0 || item.hasBogo ? (
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
{item.discountPerItem > 0 && ( {item.discountPerItem > 0 && (
@@ -600,60 +598,66 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
-₹{(item.promoDiscount || 0).toFixed(2)} -₹{(item.promoDiscount || 0).toFixed(2)}
</span> </span>
)} )}
<div className="flex gap-1 flex-wrap justify-end"> <div className="flex gap-1 flex-wrap justify-end items-center">
{item.discountPerItem > 0 && ( {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'} <Badge variant="success" label={item.couponCode || 'MANUAL'} />
<button onClick={() => removeCoupon(item.id)} className="ml-0.5 hover:text-red-500"> <IconButton
<X className="w-2.5 h-2.5" /> label={`Remove discount from ${item.name}`}
</button> variant="ghost"
</span> size="sm"
)} icon={<X className="w-3 h-3" />}
{item.hasBogo && ( onClick={() => removeCoupon(item.id)}
<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>
)} )}
{item.hasBogo && <Badge variant="warning" label="BOGO" />}
</div> </div>
</div> </div>
) : ( ) : (
<span className="text-slate-300 text-sm">—</span> <span className="text-sm block text-right opacity-30">—</span>
)} )}
</td> </TableCell>
<td className={`px-4 py-3.5 text-right font-bold text-[15px] ${item.isRefund ? 'text-red-600' : 'text-slate-900'}`}> <TableCell>
{item.isRefund ? '-' : ''}₹ <div className={`text-right font-bold text-[15px] ${item.isRefund ? 'text-red-600' : ''}`}>
{( {item.isRefund ? '-' : ''}₹
item.price * item.qty - {(
item.discountPerItem * item.qty - item.price * item.qty -
(item.promoDiscount || 0) item.discountPerItem * item.qty -
).toFixed(2)} (item.promoDiscount || 0)
</td> ).toFixed(2)}
<td className="px-5 py-3.5 text-center"> </div>
<div className="flex items-center justify-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> </TableCell>
<button <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={() => { onClick={() => {
setDiscountPromptItem(item.id); setDiscountPromptItem(item.id);
setCouponCodeInput(item.couponCode || ''); setCouponCodeInput(item.couponCode || '');
setDiscountAmountInput(item.discountPerItem > 0 ? item.discountPerItem.toString() : ''); 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" <IconButton
> label={`Remove ${item.name} from cart`}
<Tag className="w-3.5 h-3.5" /> variant="destructive"
</button> size="sm"
<button icon={<Trash2 className="w-3.5 h-3.5" />}
onClick={() => removeCartItem(item.id)} 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> </div>
</td> </TableCell>
</tr> </TableRow>
))} ))}
</tbody> </TableBody>
</table> </Table>
</div> </div>
)} )}
</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"> <div className="flex items-center gap-1.5 justify-end">
<User className="w-3 h-3 text-blue-500" /> <User className="w-3 h-3 text-blue-500" />
<span className="text-xs font-bold text-slate-900">{customer.name || customer.phone}</span> <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"> <IconButton
<X className="w-3 h-3" /> label="Detach customer from this sale"
</button> variant="ghost"
size="sm"
icon={<X className="w-3 h-3" />}
onClick={() => setCustomer(null)}
/>
</div> </div>
<div className="text-[10px] font-bold text-amber-500 mt-0.5 flex items-center justify-end gap-1"> <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 <Zap className="w-2.5 h-2.5" /> {customer.points} pts
</div> </div>
</div> </div>
) : ( ) : (
<button <Button
label="Customer"
variant="secondary"
size="sm"
icon={<Plus className="w-3.5 h-3.5" />}
onClick={() => setShowCustomerPrompt(true)} 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>
</div> </div>
@@ -761,14 +770,19 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
)} )}
</div> </div>
<div className="flex gap-2"> {/* PAY keeps its own emerald face and full width: it is the one
<button 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} 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 <button
onClick={handleCheckoutBtn} onClick={handleCheckoutBtn}
disabled={cart.length === 0 || !customer} 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"> <h3 className="font-bold text-slate-900 flex items-center gap-2">
<Search className="w-5 h-5 text-indigo-500" /> Product Search <Search className="w-5 h-5 text-indigo-500" /> Product Search
</h3> </h3>
<button onClick={() => setShowManualSearch(false)} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400"> <IconButton label="Close product search" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setShowManualSearch(false)} />
<X className="w-4 h-4" />
</button>
</div> </div>
<div className="px-5 py-3 border-b border-slate-100"> <div className="px-5 py-3 border-b border-slate-100">
<input <TextInput
type="text" label="Search products"
isLabelHidden
placeholder="Search by name, SKU…" placeholder="Search by name, SKU…"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(v) => setSearch(v)}
autoFocus startIcon={<Search className="w-4 h-4" />}
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" hasClear
hasAutoFocus
/> />
</div> </div>
<div className="flex-1 overflow-y-auto"> <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()) p.sku.toLowerCase().includes(search.toLowerCase())
) )
.map((product) => ( .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 <button
key={product.id} key={product.id}
disabled={product.closing <= 0} 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"> <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" /> <User className="w-6 h-6 text-blue-500" />
</div> </div>
<button onClick={closeCustomerPrompt} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400"> <IconButton label="Close customer panel" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={closeCustomerPrompt} />
<X className="w-4 h-4" />
</button>
</div> </div>
<h3 className="font-bold text-xl text-slate-900 mb-1">{isNewCustomer ? 'New Customer' : 'Attach Customer'}</h3> <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> <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 {/* Was `type="tel"` with no filtering, so letters reached a field
type="tel" the Find button gates on `.length >= 10` — "abcdefghij" opened
placeholder="e.g. 9876543210" a lookup. Astryx has no tel type; digits-only in the handler is
value={customerPhoneInput} the stricter fix anyway. */}
onChange={(e) => setCustomerPhoneInput(e.target.value)} <div className="mb-4">
onKeyDown={(e) => e.key === 'Enter' && !isNewCustomer && checkCustomerPhone()} <TextInput
disabled={isNewCustomer} label="Customer mobile number"
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'}`} placeholder="e.g. 9876543210"
autoFocus={!isNewCustomer} value={customerPhoneInput}
/> onChange={(v) => setCustomerPhoneInput(v.replace(/\D/g, '').slice(0, 10))}
onEnter={() => { if (!isNewCustomer) checkCustomerPhone(); }}
isDisabled={isNewCustomer}
hasAutoFocus={!isNewCustomer}
/>
</div>
{isNewCustomer && ( {isNewCustomer && (
<input <div className="mb-4">
type="text" <TextInput
placeholder="Customer Name" label="Customer name"
value={customerNameInput} placeholder="Customer Name"
onChange={(e) => setCustomerNameInput(e.target.value)} value={customerNameInput}
onKeyDown={(e) => e.key === 'Enter' && saveNewCustomer()} onChange={(v) => setCustomerNameInput(v)}
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" onEnter={saveNewCustomer}
autoFocus hasAutoFocus
/> />
</div>
)} )}
{isNewCustomer ? ( {isNewCustomer ? (
<button <Button
label="Save & Attach"
isDisabled={!customerNameInput.trim()}
onClick={saveNewCustomer} 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} 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> </div>
</PosModal> </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"> <h3 className="font-bold text-slate-900 flex items-center gap-2">
<Download className="w-5 h-5 text-blue-500" /> Parked Sales <Download className="w-5 h-5 text-blue-500" /> Parked Sales
</h3> </h3>
<button onClick={() => setShowParkedPrompt(false)} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400"> <IconButton label="Close parked sales" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setShowParkedPrompt(false)} />
<X className="w-4 h-4" />
</button>
</div> </div>
<div className="flex-1 overflow-y-auto p-4 space-y-2"> <div className="flex-1 overflow-y-auto p-4 space-y-2">
{parkedCarts.length === 0 ? ( {parkedCarts.length === 0 ? (
<div className="text-center py-12 text-slate-400"> <EmptyState isCompact icon={<Save className="w-10 h-10" />} title="No parked sales" />
<Save className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm font-medium">No parked sales</p>
</div>
) : ( ) : (
parkedCarts.map((pc) => ( 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"> <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'} {new Date(pc.time).toLocaleTimeString()} · {pc.cart.length} items · {pc.customer ? pc.customer.phone : 'Guest'}
</p> </p>
</div> </div>
<button <Button label="Retrieve" size="sm" onClick={() => retrieveCart(pc)} />
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>
</div> </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"> <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" /> <Tag className="w-6 h-6 text-emerald-600" />
</div> </div>
<button onClick={() => setDiscountPromptItem(null)} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400"> <IconButton label="Close discount panel" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setDiscountPromptItem(null)} />
<X className="w-4 h-4" />
</button>
</div> </div>
<h3 className="font-bold text-xl text-slate-900 mb-1">Apply Discount</h3> <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> <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 className="space-y-3 mb-5">
<div> <NumberInput
<label className="block text-[11px] font-bold text-slate-500 uppercase tracking-wider mb-1.5"> label="Discount Amount (₹)"
Discount Amount (₹) value={discountAmountInput === '' ? null : Number(discountAmountInput)}
</label> onChange={(v) => setDiscountAmountInput(v == null ? '' : String(v))}
<input min={0}
type="number" step={0.01}
placeholder="0.00" />
value={discountAmountInput} <TextInput
onChange={(e) => setDiscountAmountInput(e.target.value)} label="Coupon Code"
onKeyDown={(e) => e.key === 'Enter' && applyDiscountToItem()} isOptional
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" placeholder="e.g. OFF10"
autoFocus value={couponCodeInput}
/> onChange={(v) => setCouponCodeInput(v.toUpperCase())}
</div> onEnter={applyDiscountToItem}
<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>
</div> </div>
<button <Button label="Confirm Discount" onClick={applyDiscountToItem} />
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>
</div> </div>
</PosModal> </PosModal>
)} )}
@@ -994,16 +983,27 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
</p> </p>
<p className="text-4xl font-black tracking-tight text-slate-900">₹{Math.abs(grandTotal).toFixed(2)}</p> <p className="text-4xl font-black tracking-tight text-slate-900">₹{Math.abs(grandTotal).toFixed(2)}</p>
</div> </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"> <IconButton label="Close payment panel" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setShowPayment(false)} />
<X className="w-4 h-4" />
</button>
</div> </div>
<div className="p-6 space-y-5"> <div className="p-6 space-y-5">
{/* Payment Method Tabs */} {/* Payment Method Tabs */}
<div> <div>
<p className="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2.5">Payment Method</p> <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) => { {(grandTotal >= 0 ? ['cash', 'upi', 'card'] : ['cash', 'store_credit']).map((m) => {
const icons: Record<string, React.ReactNode> = { const icons: Record<string, React.ReactNode> = {
cash: <Wallet className="w-4 h-4" />, 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> = { const labels: Record<string, string> = {
cash: 'Cash', upi: 'UPI', card: 'Card', store_credit: 'Store Credit', cash: 'Cash', upi: 'UPI', card: 'Card', store_credit: 'Store Credit',
}; };
return ( return <SegmentedControlItem key={m} value={m} label={labels[m]} icon={icons[m]} />;
<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>
);
})} })}
</div> </SegmentedControl>
</div> </div>
{/* Cash Payment */} {/* Cash Payment */}
{payMethod === 'cash' && grandTotal >= 0 && ( {payMethod === 'cash' && grandTotal >= 0 && (
<div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200"> <div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200">
<div> <div className="pos-cash-field">
<label className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2"> <NumberInput
Cash Received label="Cash Received"
</label> value={cashReceived === '' ? null : Number(cashReceived)}
<input onChange={(v) => setCashReceived(v == null ? '' : String(v))}
type="number" min={0}
value={cashReceived} step={0.01}
onChange={(e) => setCashReceived(e.target.value)} hasAutoFocus
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> </div>
{/* Quick denominations */} {/* Quick denominations */}
@@ -1056,24 +1040,20 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
.filter((d) => d >= grandTotal * 0.5) .filter((d) => d >= grandTotal * 0.5)
.slice(0, 6) .slice(0, 6)
.map((d) => ( .map((d) => (
<button <Button
key={d} key={d}
label={`₹${d}`}
size="sm"
variant={parseFloat(cashReceived) === d ? 'primary' : 'secondary'}
onClick={() => setCashReceived(d.toString())} 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())} 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>
</div> </div>
{cashReceived && parseFloat(cashReceived) >= grandTotal && ( {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"> <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" /> <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> <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); }} 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>
)} )}
</div> </div>
@@ -1136,6 +1116,8 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
</div> </div>
)} )}
{/* Same exception as PAY, and for the same reason: this is the
commit point of the transaction. */}
<button <button
onClick={confirmPayment} onClick={confirmPayment}
disabled={payMethod === 'store_credit' && !customer} 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"> <h3 className="font-bold text-slate-900 flex items-center gap-2">
<Receipt className="w-4 h-4" /> Receipt <Receipt className="w-4 h-4" /> Receipt
</h3> </h3>
<button onClick={resetPos} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400"> <IconButton label="Close receipt" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={resetPos} />
<X className="w-4 h-4" />
</button>
</div> </div>
<div className="p-6 font-mono text-sm text-black print:p-0"> <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>
<div className="px-5 py-4 border-t border-slate-100 bg-slate-50 flex gap-3 print:hidden"> <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"> <Button label="New Sale" variant="secondary" onClick={resetPos} />
New Sale <Button label="Print" icon={<Receipt className="w-4 h-4" />} onClick={() => window.print()} />
</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>
</div> </div>
</PosModal> </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"> <h3 className="font-bold text-slate-900 flex items-center gap-2">
<PieChart className="w-5 h-5 text-emerald-500" /> Daily Sales Report <PieChart className="w-5 h-5 text-emerald-500" /> Daily Sales Report
</h3> </h3>
<button onClick={() => setShowReport(false)} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400"> <IconButton label="Close daily report" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setShowReport(false)} />
<X className="w-4 h-4" />
</button>
</div> </div>
<div className="p-5 max-h-[70vh] overflow-y-auto space-y-5"> <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 { import {
Building2, Building2,
Store, Store,
Truck,
CreditCard,
SlidersHorizontal,
Users, Users,
MapPin, MapPin,
Phone, Phone,
Mail, Mail,
Plus, Plus,
Bike X
} from 'lucide-react'; } from 'lucide-react';
import { useFiestaAllTenants, useFiestaTenantLocations, useFiestaUpdateLocation, useFiestaDeleteLocation } from '../services/fiestaQueries'; import { useFiestaAllTenants, useFiestaTenantLocations, useFiestaUpdateLocation, useFiestaDeleteLocation } from '../services/fiestaQueries';
import { useAppRoles } from '../services/queries'; import { useAppRoles } from '../services/queries';
@@ -26,9 +23,47 @@ import AdminConsole from './AdminConsole';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
import type { AuthUser } from '../services/auth'; import type { AuthUser } from '../services/auth';
import { useNotify } from './Toasts'; 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'; 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). */ /** Locally-persisted merchant preferences (survive reload via localStorage). */
interface MerchantSettings { interface MerchantSettings {
// Business profile (seeded from live tenant data, then locally editable) // 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 Icon = t.icon;
const active = activeTab === t.key; const active = activeTab === t.key;
return ( return (
<button <Button
key={t.key} key={t.key}
label={t.label}
variant={active ? 'primary' : 'ghost'}
icon={<Icon size={16} />}
onClick={() => setActiveTab(t.key)} 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> </nav>
@@ -388,29 +418,27 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
<div className="space-y-sm"> <div className="space-y-sm">
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">Customer Support & Contacts</span> <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="grid grid-cols-1 sm:grid-cols-2 gap-md mt-2">
<div className="space-y-1.5"> {/* PHASE 6 — both were `readOnly` inputs styled to look like
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px] flex items-center gap-1.5"> fields: focusable controls in the tab order that could never
<Mail size={12} className="text-slate-400" /> Support Email be edited, sitting in a section the comment above already
</label> calls "read-only". They are values, so they read as values.
<input Every other card in this panel already displays live tenant
type="email" data exactly this way. */}
value={user?.email || fstr(tenant?.primaryemail) || ''} <div className="p-4 bg-white rounded-xl border border-slate-200/60 shadow-sm">
readOnly <span className="text-[10px] text-slate-400 uppercase font-black tracking-wider flex items-center gap-1.5">
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" <Mail size={12} /> Support Email
placeholder="—" </span>
/> <p className="font-bold text-slate-800 text-sm mt-1 break-all">
{user?.email || fstr(tenant?.primaryemail) || '—'}
</p>
</div> </div>
<div className="space-y-1.5"> <div className="p-4 bg-white rounded-xl border border-slate-200/60 shadow-sm">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px] flex items-center gap-1.5"> <span className="text-[10px] text-slate-400 uppercase font-black tracking-wider flex items-center gap-1.5">
<Phone size={12} className="text-slate-400" /> Phone Number <Phone size={12} /> Phone Number
</label> </span>
<input <p className="font-bold text-slate-800 text-sm mt-1 font-mono">
type="text" {user?.contactno || fstr(tenant?.primarycontact) || '—'}
value={user?.contactno || fstr(tenant?.primarycontact) || ''} </p>
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> </div>
</div> </div>
</div> </div>
@@ -429,13 +457,12 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
</h2> </h2>
</div> </div>
<button <Button
type="button" type="button"
label={showStoreOnboarding ? 'View Store Directory' : 'Add Store Branch'}
icon={showStoreOnboarding ? undefined : <Plus size={14} />}
onClick={() => setShowStoreOnboarding(!showStoreOnboarding)} 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> </div>
{showStoreOnboarding ? ( {showStoreOnboarding ? (
@@ -445,9 +472,14 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
) : ( ) : (
<> <>
{locationsQ.isLoading ? ( {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 ? ( ) : 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"> <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) => ( {cleanOutlets.map((loc, i) => (
@@ -467,31 +499,33 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
</p> </p>
</div> </div>
</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"> <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 ${ <Badge
loc.status.toLowerCase() === 'active' variant={loc.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
? 'text-emerald-700 bg-emerald-50 border border-emerald-100/50' label={loc.status || '—'}
: 'text-zinc-555 bg-zinc-100' />
}`}> <Button
{loc.status || '—'}
</span>
<button
type="button" type="button"
label="Edit"
aria-label={`Edit ${loc.locationname}`}
variant="secondary"
size="sm"
onClick={() => { onClick={() => {
setEditingStore({ ...loc }); setEditingStore({ ...loc });
setShowEditStoreModal(true); 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" />
> <Button
Edit
</button>
<button
type="button" type="button"
label="Delete"
aria-label={`Delete ${loc.locationname}`}
variant="destructive"
size="sm"
onClick={() => handleDeleteStore(loc)} 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>
</div> </div>
@@ -534,23 +568,24 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
{/* EDIT STORE MODAL */} {/* EDIT STORE MODAL */}
{showEditStoreModal && editingStore && ( {showEditStoreModal && editingStore && (
<div <SettingsModal onClose={() => setShowEditStoreModal(false)} width={450}>
className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-sm z-[200] flex items-center justify-center p-4" <div className="flex flex-col max-h-[90vh]">
onClick={(e) => { if (e.target === e.currentTarget) setShowEditStoreModal(false); }} <div className="p-4 flex justify-between items-center shrink-0">
> <Heading level={3}>
<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()}> <span className="flex items-center gap-2">
<div className="p-4 border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0"> <Store size={16} className="text-[#662582]" />
<h4 className="font-bold text-[#0f172a] text-sm flex items-center gap-2"> Edit Store Outlet Location
<Store size={16} className="text-[#662582]" /> </span>
Edit Store Outlet Location </Heading>
</h4> {/* Was a literal "✕" character in a bare button, so it
<button announced as "multiplication x" and carried no name. */}
type="button" <IconButton
label="Close edit outlet"
variant="ghost"
size="sm"
icon={<X size={16} />}
onClick={() => setShowEditStoreModal(false)} 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> </div>
<form onSubmit={handleUpdateStoreSubmit} className="flex flex-col flex-1 min-h-0"> <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>
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<div className="space-y-1"> <TextInput
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">OUTLET NAME (*)</label> label="Outlet name" isRequired
<input placeholder="e.g. Coimbatore Main"
type="text" value={editingStore.locationname}
placeholder="e.g. Coimbatore Main" onChange={(v) => setEditingStore({ ...editingStore, locationname: v })}
value={editingStore.locationname} />
onChange={(e) => setEditingStore({ ...editingStore, locationname: e.target.value })} <TextInput
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" label="Local zone area" isRequired
required placeholder="e.g. RS Puram"
/> value={editingStore.suburb}
</div> onChange={(v) => setEditingStore({ ...editingStore, suburb: v })}
/>
<div className="space-y-1"> <TextInput
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">LOCAL ZONE AREA (*)</label> label="City"
<input placeholder="e.g. Coimbatore"
type="text" value={editingStore.city}
placeholder="e.g. RS Puram" onChange={(v) => setEditingStore({ ...editingStore, city: v })}
value={editingStore.suburb} />
onChange={(e) => setEditingStore({ ...editingStore, suburb: e.target.value })} {/* The phone field accepted anything at all. Its sibling in
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" AdminConsole already strips non-digits and caps at 10; this
required one edits the same column and did neither. */}
/> <TextInput
</div> label="Contact phone" isRequired
placeholder="e.g. 9876543210"
<div className="space-y-1"> value={editingStore.contactno}
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">CITY</label> onChange={(v) => setEditingStore({ ...editingStore, contactno: v.replace(/\D/g, '').slice(0, 10) })}
<input startIcon={<Phone size={14} />}
type="text" />
placeholder="e.g. Coimbatore" {/* Option values are lower-case because that is what the
value={editingStore.city} column actually holds — verified against gettenantlocations:
onChange={(e) => setEditingStore({ ...editingStore, city: e.target.value })} every outlet reads "active", not "Active". The old options
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" were capitalised, so the select never matched the stored
/> value and showed nothing selected; saving then wrote
</div> whatever it resolved to, which could flip a live store's
status without anyone choosing it. Labels stay capitalised
<div className="space-y-1"> for display. */}
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">CONTACT PHONE (*)</label> <Selector
<input label="Status"
type="text" value={String(editingStore.status || '').toLowerCase()}
placeholder="e.g. 9876543210" onChange={(v) => setEditingStore({ ...editingStore, status: v || 'active' })}
value={editingStore.contactno} options={[
onChange={(e) => setEditingStore({ ...editingStore, contactno: e.target.value })} { value: 'active', label: 'Active' },
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" { value: 'inactive', label: 'Inactive' },
required { value: 'suspended', label: 'Suspended' },
/> ]}
</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>
</div> </div>
</div> </div>
<div className="p-4 border-t border-[#f1f5f9] flex justify-end gap-3 bg-[#f8fafc] shrink-0"> <div className="p-4 flex justify-end gap-3 shrink-0">
<button <Button type="button" label="Cancel" variant="secondary" onClick={() => setShowEditStoreModal(false)} />
type="button" <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
type="submit" type="submit"
disabled={updateLocationMut.isPending} label={updateLocationMut.isPending ? 'Updating…' : 'Update Outlet Profile'}
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" isLoading={updateLocationMut.isPending}
> isDisabled={updateLocationMut.isPending}
{updateLocationMut.isPending ? 'Updating...' : 'Update Outlet Profile'} />
</button>
</div> </div>
</form> </form>
</div> </div>
</div> </SettingsModal>
)} )}
<ConfirmModal <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 { num as fnum, productImages, str as fstr, type Row } from '../services/fiestaApi';
import { useStoreCatalogue, isPublishedItem } from '../services/storeCatalogue'; import { useStoreCatalogue, isPublishedItem } from '../services/storeCatalogue';
import AwaitingApi from './AwaitingApi'; 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 { CardCarousel, ProductGallery } from './CatalogueGallery';
import FMCGHoverOverlay from './FMCGHoverOverlay'; import FMCGHoverOverlay from './FMCGHoverOverlay';
import CatalogueDetailPanel from './CatalogueDetailPanel'; import CatalogueDetailPanel from './CatalogueDetailPanel';
@@ -39,6 +39,43 @@ import { TextInput } from '@astryxdesign/core/TextInput';
import { NumberInput } from '@astryxdesign/core/NumberInput'; import { NumberInput } from '@astryxdesign/core/NumberInput';
import { DateInput } from '@astryxdesign/core/DateInput'; import { DateInput } from '@astryxdesign/core/DateInput';
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; 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'; 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 */} {/* Tabs and Controls */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 w-full"> <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"> {/* PHASE 6 — the three view buttons were a hand-built segmented control:
<button they looked like one but announced as three unrelated buttons with no
onClick={() => setView('catalogue')} indication which view was active. SegmentedControl is the real thing,
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 ${ with a radiogroup and arrow-key navigation. */}
view === 'catalogue' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800' <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}) <SegmentedControlItem value="catalogue" label={`Browse Catalogue (${products.length})`} icon={<Boxes size={14} />} />
</button> <SegmentedControlItem value="inventory" label={`My Store Inventory (${inventory.length})`} icon={<Store size={14} />} />
<button <SegmentedControlItem value="requests" label={`My Requests (${Object.keys(picks).length})`} icon={<Inbox size={14} />} />
onClick={() => setView('inventory')} </SegmentedControl>
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>
</div> </div>
{/* Counter sales. Deliberately outside the tab group and always visible: {/* Counter sales. Deliberately outside the tab group and always visible:
it is an action, not another way of looking at the catalogue, and the 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 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. */} is passed — a store user can only ever import against their own. */}
<button {/* The `title` moves to the wrapper. A disabled control emits no pointer
onClick={() => setShowOfflineSales(true)} events, so on the button itself the tooltip explaining *why* it was
disabled={!storeLocationId} disabled never appeared — on the one state that needed it. Button has
no `disabledMessage` (that is a Selector/RadioList prop). */}
<span
title={ title={
storeLocationId storeLocationId
? 'Upload an Excel sheet of counter sales to reduce your stock' ? 'Upload an Excel sheet of counter sales to reduce your stock'
: 'Your store outlet is still loading' : '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:" {/* PHASE 6 — DateInput carries its own label, so the separate "Date:"
caption is dropped rather than duplicated beside it. The comment sits 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) && ( {(selectedCategories.length > 0 || stockHealthFilter.length > 0 || search) && (
<button <div className="mt-2">
onClick={() => { setSelectedCategories([]); setStockHealthFilter([]); setSearch(''); }} <Button
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" label="Clear all filters"
> variant="secondary"
Clear All Filters size="sm"
</button> onClick={() => { setSelectedCategories([]); setStockHealthFilter([]); setSearch(''); }}
/>
</div>
)} )}
</div> </div>
</div> </div>
@@ -516,12 +550,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
</div> </div>
{/* Action Toolbar */} {/* Action Toolbar */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <Button
label="Filter"
variant="secondary"
size="sm"
icon={<Layers size={14} />}
onClick={() => setIsLocalSidebarOpen(!isLocalSidebarOpen)} 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 {/* `hasClear` replaces the absolutely-positioned X button, and
`startIcon` replaces the absolutely-positioned magnifier — so `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" />} <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}) {picks[p.id].status === 'Pending' ? 'Requested' : picks[p.id].status} ({picks[p.id].qty})
</span> </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"> <div className="flex gap-1">
{picks[p.id].status === 'Pending' && ( {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' && ( {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 ${ <IconButton
picks[p.id].status === 'Rejected' ? 'text-rose-500 border-rose-200' : label={`Remove ${p.name} from your request`}
'text-amber-500 border-amber-200' variant="secondary"
}`}><X size={12} /></button> size="sm"
icon={<X size={12} />}
onClick={(e: React.MouseEvent) => { e.stopPropagation(); togglePick(p.id); }}
/>
)} )}
</div> </div>
</div> </div>
) : ( ) : (
<button <Button
onClick={(e) => { e.stopPropagation(); setTempQty(1); setActiveQtyProduct(p); }} label="Add to Store"
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" aria-label={`Add ${p.name} to your store`}
> variant="secondary"
<Plus size={12} /> Add to Store size="sm"
</button> icon={<Plus size={12} />}
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setTempQty(1); setActiveQtyProduct(p); }}
/>
)} )}
</div> </div>
</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." 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"> <div className="overflow-x-auto">
<table className="w-full text-left border-collapse" style={{ minWidth: 800 }}> <Table style={{ minWidth: 800 }}>
<thead> <TableHeader>
<tr className="bg-slate-50/80 border-b border-slate-200"> <TableRow>
{['Requested At', 'Store', 'Product', 'Qty', 'Status', 'Resolved At', ''].map((h, i) => ( {['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> </TableRow>
</thead> </TableHeader>
<tbody className="divide-y divide-slate-100"> <TableBody>
{(stockRequestsQ.data || []).map((data: any) => { {(stockRequestsQ.data || []).map((data: any) => {
const pid = String(data.productid); const pid = String(data.productid);
const prod = products.find(p => String(p.id) === pid); const prod = products.find(p => String(p.id) === pid);
const isApproved = data.status === 'Approved'; const isApproved = data.status === 'Approved';
const isRejected = data.status === 'Rejected'; // Same mapping as the admin side of this flow: 'Received'
const isCancelled = data.status === 'Cancelled'; // was indigo and 'Cancelled' slate, neither of which has a
const isReceived = data.status === 'Received'; // 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 ( return (
<tr key={data.requestid} className="transition-all duration-200 hover:bg-slate-50/80 group"> <TableRow key={data.requestid} className="group">
<td className="px-5 py-4 whitespace-nowrap"> <TableCell>
<div className="flex flex-col"> <div className="flex flex-col whitespace-nowrap">
<span className="text-sm font-semibold text-slate-900"> <span className="text-sm font-semibold">
{data.created ? new Date(data.created).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'} {data.created ? new Date(data.created).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'}
</span> </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' }) : ''} {data.created ? new Date(data.created).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }) : ''}
</span> </span>
</div> </div>
</td> </TableCell>
<td className="px-5 py-4 whitespace-nowrap"> <TableCell>
<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"> <span className="inline-flex items-center gap-2 whitespace-nowrap">
<Store size={14} className="text-indigo-500" /> <Store size={14} className="text-indigo-500" />
<span className="font-bold text-xs">{storeName}</span> <span className="font-bold text-xs">{storeName}</span>
</div> </span>
</td> </TableCell>
<td className="px-5 py-4"> <TableCell>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<> <div className="relative w-10 h-10 rounded-xl overflow-hidden shrink-0">
<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="" className="w-full h-full object-cover" />
<img src={data.productimage || prod?.image || PLACEHOLDER} alt={data.productname || prod?.name || 'Product'} className="w-full h-full object-cover" /> </div>
</div> <div className="min-w-0 flex-1">
<div className="min-w-0 flex-1"> <p className="font-bold text-sm truncate">{data.productname || prod?.name}</p>
<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 truncate mt-0.5 opacity-60">{prod?.sku || `SKU-${data.productid}`}</p>
<p className="text-xs font-medium text-slate-500 truncate mt-0.5">{prod?.sku || `SKU-${data.productid}`}</p> </div>
</div>
</>
</div> </div>
</td> </TableCell>
<td className="px-5 py-4 whitespace-nowrap"> <TableCell>
<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"> <span className="font-bold text-sm tabular-nums">{data.qty || '—'}</span>
{data.qty || '—'} </TableCell>
</span> <TableCell>
</td> <Badge variant={statusVariant} label={data.status || '—'} />
<td className="px-5 py-4 whitespace-nowrap"> </TableCell>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${ <TableCell>
isReceived ? 'bg-indigo-50 text-indigo-700 border-indigo-200' : <span className="text-xs font-medium whitespace-nowrap opacity-60">
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">
{data.updated ? new Date(data.updated).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'} {data.updated ? new Date(data.updated).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'}
</span> </span>
</td> </TableCell>
<td className="px-5 py-4 whitespace-nowrap text-right"> <TableCell>
{isApproved && ( {isApproved && (
<button <div className="text-right">
onClick={(e) => { <Button
e.stopPropagation(); label="Mark as Received"
handleMarkReceived(data); aria-label={`Mark ${data.productname || prod?.name || 'product'} as received`}
}} size="sm"
disabled={updateRequestMutation.isPending} icon={<CheckCircle2 size={14} />}
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" isDisabled={updateRequestMutation.isPending}
> onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleMarkReceived(data); }}
<CheckCircle2 size={14} /> Mark as Received />
</button> </div>
)} )}
</td> </TableCell>
</tr> </TableRow>
); );
})} })}
</tbody> </TableBody>
</table> </Table>
</div> </div>
</div> </ShellCard>
)} )}
</div> </div>
)} )}
@@ -756,9 +793,11 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
title="No stock matches your filters" title="No stock matches your filters"
sub="Try a different keyword or clear the sidebar filters to find an item in your store." sub="Try a different keyword or clear the sidebar filters to find an item in your store."
action={ 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"> <Button
<X size={13} /> Clear filters label="Clear filters"
</button> 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> <span>Stock: <strong className="font-extrabold font-mono text-slate-900">{(it.closing ?? 0).toLocaleString('en-IN')}</strong></span>
</div> </div>
<button {/* Was a <button> with no onClick — a control that did
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" 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')} Stock Level: {(it.closing ?? 0).toLocaleString('en-IN')}
</button> </div>
</div> </div>
</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="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"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold">{pickCount} product{pickCount > 1 ? 's' : ''} requested</span> <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> </div>
<AwaitingApi label="Request submitted to Admin" api="stock-request API" compact className="bg-white/5 border-white/15 text-emerald-300" /> <AwaitingApi label="Request submitted to Admin" api="stock-request API" compact className="bg-white/5 border-white/15 text-emerald-300" />
</div> </div>
@@ -825,12 +866,8 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
{/* QUANTITY SELECTION CENTERED MODAL */} {/* QUANTITY SELECTION CENTERED MODAL */}
{activeQtyProduct && ( {activeQtyProduct && (
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4"> <CatalogueModal onClose={() => setActiveQtyProduct(null)} width={340}>
<div <div className="p-5 flex flex-col gap-4">
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">
{/* Header */} {/* Header */}
<div className="flex justify-between items-center pb-2 border-b border-slate-100/80"> <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> <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> <p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">Bulk Order</p>
</div> </div>
<button <IconButton
label="Close request stock"
variant="ghost"
size="sm"
icon={<X size={14} strokeWidth={2.5} />}
onClick={() => setActiveQtyProduct(null)} 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> </div>
{/* Product Card summary */} {/* 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> <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"> <div className="grid grid-cols-4 gap-2">
{[10, 25, 50, 100].map(qty => ( {[10, 25, 50, 100].map(qty => (
<button <Button
key={qty} key={qty}
label={String(qty)}
aria-label={`Request ${qty} units`}
size="sm"
variant={tempQty === qty ? 'primary' : 'secondary'}
onClick={() => setTempQty(qty)} 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>
</div> </div>
@@ -903,21 +938,20 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
</span> </span>
</div> </div>
<button <Button
label="Confirm Request"
icon={<CheckCircle2 size={14} strokeWidth={2.5} />}
onClick={() => { onClick={() => {
setPickQty(activeQtyProduct.id, tempQty); setPickQty(activeQtyProduct.id, tempQty);
setActiveQtyProduct(null); setActiveQtyProduct(null);
setNotice(true); setNotice(true);
setTimeout(() => setNotice(false), 3000); 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> </div>
</div> </CatalogueModal>
)} )}
{/* Floating FMCG Details Panel */} {/* Floating FMCG Details Panel */}
@@ -997,16 +1031,15 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
<CheckCircle2 size={18} className="text-emerald-600" /> <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> <span className="text-sm font-semibold text-emerald-800">Approved <span className="font-bold">({pick.qty} units)</span> — confirm once it arrives</span>
</div> </div>
<button <Button
label="Mark as Received"
icon={<CheckCircle2 size={18} />}
isDisabled={updateRequestMutation.isPending}
onClick={() => { onClick={() => {
setSelectedProduct(null); setSelectedProduct(null);
handleMarkReceived({ productid: selectedProduct.id, qty: pick.qty, requestid: pick.requestid }); 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> </div>
) : isRejected ? ( ) : isRejected ? (
<div className="flex flex-col gap-3"> <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" /> <X size={18} className="text-rose-600" />
<span className="text-sm font-semibold text-rose-800">Stock request was rejected</span> <span className="text-sm font-semibold text-rose-800">Stock request was rejected</span>
</div> </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"> <Button label="Clear Request" variant="secondary" onClick={() => { setSelectedProduct(null); togglePick(selectedProduct.id); }} />
Clear Request
</button>
</div> </div>
) : isPending ? ( ) : isPending ? (
<div className="flex flex-col gap-3"> <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" /> <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> <span className="text-sm font-semibold text-amber-800">Requested <span className="font-bold">({pick.qty} units)</span></span>
</div> </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"> <Button label="Edit" variant="secondary" size="sm" onClick={() => { setSelectedProduct(null); setTempQty(pick.qty); setActiveQtyProduct(selectedProduct); }} />
Edit
</button>
</div> </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"> <Button label="Cancel Request" variant="destructive" icon={<X size={18} strokeWidth={2} />} onClick={() => { setSelectedProduct(null); togglePick(selectedProduct.id); }} />
<X size={18} strokeWidth={2} /> Cancel Request
</button>
</div> </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]"> <Button label="Request Stock" icon={<Plus size={18} strokeWidth={2} />} onClick={() => { setSelectedProduct(null); setTempQty(1); setActiveQtyProduct(selectedProduct); }} />
<Plus size={18} strokeWidth={2} /> Request Stock
</button>
)} )}
</div> </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 { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete'; import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
import { useNotify } from './Toasts'; 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 { interface UsersPanelProps {
tenantId?: number; tenantId?: number;
@@ -326,72 +367,66 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</div> </div>
</div> </div>
<button <Button label="Add Team Member" icon={<Plus size={16} />} onClick={() => setShowAddUserModal(true)} />
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>
</div> </div>
{/* Search & Filter Utility Bar */} {/* 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="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"> {/* Measured in Chrome: this collapsed to 8px after the swap. The old
<div className="relative flex w-full items-center border-b-2 border-slate-200 focus-within:border-purple-600 transition-colors duration-300"> `<input>` carried its own intrinsic width; TextInput does not, so the
<span className="pl-1 flex items-center pointer-events-none text-slate-400"> scrollable filter row beside it claimed the whole line. A fixed width
<Search size={16} strokeWidth={3} /> here plus `min-w-0` on that row is what actually holds it open —
</span> flex-basis alone did not. */}
<input <div className="w-full md:w-96 shrink-0">
type="text" <TextInput
placeholder="Search team members..." label="Search team members"
value={search} isLabelHidden
onChange={(e) => setSearch(e.target.value)} placeholder="Search team members..."
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" value={search}
/> onChange={(v) => setSearch(v)}
{search && ( startIcon={<Search size={16} />}
<button hasClear
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>
</div> </div>
{/* Role filter capsules */} {/* Role filter — a single-select, so a real one. As a row of buttons
<div className="flex items-center gap-2 overflow-x-auto pb-1 md:pb-0 scrollbar-none select-none"> nothing announced which filter was active; SegmentedControl emits a
<button radiogroup with arrow-key navigation. */}
onClick={() => setUserRoleFilter('ALL')} <div className="min-w-0 overflow-x-auto pb-1 md:pb-0 scrollbar-none select-none">
className={`px-3.5 py-2 rounded-xl text-xs uppercase tracking-wider font-extrabold transition-all duration-200 cursor-pointer border ${ <SegmentedControl
userRoleFilter === 'ALL' label="Filter by role"
? 'bg-purple-600 text-white border-purple-600 shadow-sm' size="sm"
: 'bg-white text-slate-600 border-slate-200/80 hover:text-slate-800 hover:bg-slate-100' value={userRoleFilter}
}`} onChange={(v) => setUserRoleFilter(v)}
> >
All Roles <SegmentedControlItem value="ALL" label="All Roles" />
</button> {roleOptions.map((roleNameStr) => (
{roleOptions.map((roleNameStr) => ( <SegmentedControlItem key={roleNameStr} value={roleNameStr} label={roleNameStr} />
<button ))}
key={roleNameStr} </SegmentedControl>
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>
))}
</div> </div>
</div> </div>
{/* Directory Grid */} {/* Directory Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredUsers.length === 0 ? ( {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"> <div className="col-span-full">
{usersQ.isLoading ? 'Loading team list…' : 'No matches found in team list.'} <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> </div>
) : ( ) : (
filteredUsers.map((u) => { filteredUsers.map((u) => {
@@ -453,17 +488,17 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
{roleInfo.label} {roleInfo.label}
</span> </span>
{u.roleid !== 1 && ( {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)} 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> </div>
@@ -477,26 +512,26 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
{/* CREATE NEW USER MODAL */} {/* CREATE NEW USER MODAL */}
{showAddUserModal && ( {showAddUserModal && (
<div <UsersModal onClose={() => setShowAddUserModal(false)} width={480}>
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md select-none" <div className="flex flex-col max-h-[90vh] text-sm font-sans">
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">
{/* Modal Header */} {/* Modal Header */}
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center shrink-0"> <div className="p-5 flex justify-between items-center shrink-0">
<h4 className="font-bold text-slate-900 flex items-center gap-2.5 text-base"> <Heading level={3}>
<div className="w-8 h-8 rounded-lg bg-purple-50 text-purple-650 flex items-center justify-center"> <span className="flex items-center gap-2.5">
<Users size={16} /> <div className="w-8 h-8 rounded-lg bg-purple-50 text-purple-650 flex items-center justify-center">
</div> <Users size={16} />
Add Team Member </div>
</h4> Add Team Member
<button </span>
</Heading>
<IconButton
label="Close add team member"
variant="ghost"
size="sm"
icon={<X size={18} />}
onClick={() => setShowAddUserModal(false)} 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> </div>
{/* Modal Form */} {/* Modal Form */}
@@ -507,147 +542,93 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
{/* Firstname */} <TextInput
<div className="space-y-1.5"> label="First name" isRequired
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">FIRSTNAME (*)</label> placeholder="e.g. Harini"
<input value={newUser.firstname}
type="text" onChange={(v) => setNewUser({ ...newUser, firstname: v })}
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>
{/* Contactno */} <TextInput
<div className="space-y-1.5"> label="Contact number" isRequired
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">CONTACTNO (*)</label> placeholder="e.g. 9988776655"
<div className="relative"> value={newUser.contactno}
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400"> onChange={(v) => setNewUser({ ...newUser, contactno: v.replace(/\D/g, '').slice(0, 10) })}
<Phone size={14} /> startIcon={<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>
{/* Email */} <TextInput
<div className="space-y-1.5"> label="Email" type="email" isRequired
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">EMAIL (*)</label> placeholder="e.g. harini@store.com"
<div className="relative"> value={newUser.email}
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400"> onChange={(v) => setNewUser({ ...newUser, email: v })}
<Mail size={14} /> startIcon={<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>
{/* Interactive Role Buttons instead of standard select */} {/* RadioList, not the old two-column grid of buttons. This is
<div className="space-y-2"> a single-select and each choice carries a description, which
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">SELECT ACCOUNT ROLE (*)</label> RadioListItem takes directly. As buttons the selection lived
<div className="grid grid-cols-2 gap-2.5"> only in border colour and announced nothing. The per-role
{roleChoices.map((r) => { icons are dropped — RadioListItem has no icon slot, and real
const isSelected = newUser.roleid === r.id; radio semantics are worth more here than the glyph. */}
const Icon = r.icon; <RadioList
return ( label="Select account role"
<button isRequired
key={r.id} value={String(newUser.roleid)}
type="button" onChange={(v) => setNewUser({ ...newUser, roleid: Number(v) })}
onClick={() => setNewUser({ ...newUser, roleid: r.id })} >
className={`p-3 rounded-xl border text-left transition-all cursor-pointer flex gap-2.5 items-start ${ {roleChoices.map((r) => (
isSelected <RadioListItem key={r.id} value={String(r.id)} label={r.label} description={r.desc} />
? 'bg-purple-50 border-purple-500 ring-2 ring-purple-500/10' ))}
: 'bg-slate-50/40 hover:bg-slate-50 border-slate-200/80' </RadioList>
}`}
>
<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>
{/* Store / branch — binds the user to a specific outlet (tenantid + locationid + applocationid) */} {/* Store / branch — binds the user to a specific outlet (tenantid + locationid + applocationid) */}
<div className="space-y-1.5"> <Selector
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">STORE / BRANCH (*)</label> label="Store / branch" isRequired
<div className="relative"> value={newUser.locationid ? String(newUser.locationid) : ''}
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400"> onChange={(v) => {
<Store size={14} /> const lid = Number(v) || 0;
</span> const s = storeOptions.find((o) => o.locationid === lid);
<select setNewUser({ ...newUser, locationid: lid, applocationid: s?.applocationid ?? 0 });
value={newUser.locationid} }}
onChange={(e) => { isLoading={locationsQ.isLoading}
const lid = Number(e.target.value); placeholder={locationsQ.isLoading ? 'Loading stores…' : storeOptions.length ? 'Select a store…' : 'No stores found'}
const s = storeOptions.find((o) => o.locationid === lid); startIcon={<Store size={14} />}
setNewUser({ ...newUser, locationid: lid, applocationid: s?.applocationid ?? 0 }); options={storeOptions.map((s) => ({
}} value: String(s.locationid),
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" label: `${s.name}${s.address ? ` — ${s.address}` : ''}`,
> }))}
<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>
{/* Rider shift — only when a rider role is selected */} {/* Rider shift — only when a rider role is selected */}
{isRiderRole && ( {isRiderRole && (
<div className="space-y-1.5 animate-in slide-in-from-top duration-250"> <div className="animate-in slide-in-from-top duration-250">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">RIDER SHIFT</label> <Selector
<div className="relative"> label="Rider shift"
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400"> value={newUser.shiftid ? String(newUser.shiftid) : ''}
<Clock size={14} /> onChange={(v) => setNewUser({ ...newUser, shiftid: Number(v) || 0 })}
</span> isLoading={shiftsQ.isLoading}
<select placeholder={shiftsQ.isLoading ? 'Loading shifts…' : shiftOptions.length ? 'Select rider shift…' : 'No shifts available'}
value={newUser.shiftid} startIcon={<Clock size={14} />}
onChange={(e) => setNewUser({ ...newUser, shiftid: Number(e.target.value) })} options={shiftOptions.map((s) => ({ value: String(s.id), label: s.label }))}
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> </div>
)} )}
{/* Address — keyless autocomplete that fills suburb / city / state / postcode */} {/* Address — keyless autocomplete that fills suburb / city / state / postcode */}
<div className="space-y-1.5"> <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…" /> <AddressAutocomplete value={newUser.address} onSelect={handleAddressSelect} placeholder="Search address…" />
</div> </div>
<div className="grid grid-cols-2 gap-sm"> <div className="grid grid-cols-2 gap-sm">
{(['suburb', 'city', 'state', 'postcode'] as const).map((key) => ( {(['suburb', 'city', 'state', 'postcode'] as const).map((key) => (
<div className="space-y-1.5" key={key}> <TextInput
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">{key.toUpperCase()}</label> key={key}
<input label={key.charAt(0).toUpperCase() + key.slice(1)}
type="text" value={newUser[key]}
value={newUser[key]} onChange={(v) => setNewUser({ ...newUser, [key]: v })}
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>
))} ))}
</div> </div>
@@ -655,57 +636,41 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</div> </div>
{/* Modal Footer */} {/* Modal Footer */}
<div className="p-5 border-t border-slate-100 flex justify-end gap-sm bg-slate-50/50 shrink-0"> <div className="p-5 flex justify-end gap-sm shrink-0">
<button <Button type="button" label="Cancel" variant="secondary" onClick={() => setShowAddUserModal(false)} />
type="button" <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
type="submit" type="submit"
disabled={createUserMut.isPending} label={createUserMut.isPending ? 'Creating…' : 'Add Member'}
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" icon={<Check size={14} />}
> isLoading={createUserMut.isPending}
{createUserMut.isPending ? ( isDisabled={createUserMut.isPending}
<> />
<span className="w-2 h-2 rounded-full bg-white animate-pulse" />
Creating…
</>
) : (
<>
<Check size={14} />
Add Member
</>
)}
</button>
</div> </div>
</form> </form>
</div> </div>
</div> </UsersModal>
)} )}
{/* ASSIGN USER MODAL */} {/* ASSIGN USER MODAL */}
{assignUserId !== null && ( {assignUserId !== null && (
<div <UsersModal onClose={() => setAssignUserId(null)} width={448}>
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md select-none" <div className="flex flex-col text-sm font-sans">
onClick={(e) => { if (e.target === e.currentTarget) setAssignUserId(null); }} <div className="p-5 flex justify-between items-center shrink-0">
> <Heading level={3}>
<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"> <span className="flex items-center gap-2.5">
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center shrink-0"> <div className="w-8 h-8 rounded-lg bg-emerald-50 text-emerald-650 flex items-center justify-center">
<h4 className="font-bold text-slate-900 flex items-center gap-2.5 text-base"> <UserCheck size={16} />
<div className="w-8 h-8 rounded-lg bg-emerald-50 text-emerald-650 flex items-center justify-center"> </div>
<UserCheck size={16} /> Assign Team Member
</div> </span>
Assign Team Member </Heading>
</h4> <IconButton
<button label="Close assign team member"
variant="ghost"
size="sm"
icon={<X size={18} />}
onClick={() => setAssignUserId(null)} 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> </div>
<form onSubmit={async (e) => { <form onSubmit={async (e) => {
@@ -723,60 +688,38 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
notifyFail(err, 'Could not assign user.'); notifyFail(err, 'Could not assign user.');
} }
}} className="p-6 space-y-5"> }} className="p-6 space-y-5">
<div className="space-y-1.5"> <Selector
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">ROLE (*)</label> label="Role" isRequired
<div className="relative"> value={String(assignRoleId)}
<select onChange={(v) => setAssignRoleId(Number(v) || assignRoleId)}
value={assignRoleId} options={roleChoices.map((r) => ({ value: String(r.id), label: r.label }))}
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>
<div className="space-y-1.5"> <Selector
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">STORE / LOCATION (*)</label> label="Store / location" isRequired
<div className="relative"> value={assignLocationId ? String(assignLocationId) : ''}
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400"> onChange={(v) => setAssignLocationId(Number(v) || 0)}
<Store size={14} /> isLoading={locationsQ.isLoading}
</span> placeholder={locationsQ.isLoading ? 'Loading stores…' : 'Select a store…'}
<select startIcon={<Store size={14} />}
value={assignLocationId} options={storeOptions.map((s) => ({
onChange={(e) => setAssignLocationId(Number(e.target.value))} value: String(s.locationid),
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" label: `${s.name}${s.address ? ` — ${s.address}` : ''}`,
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>
<div className="pt-4 border-t border-slate-100 flex justify-end gap-sm mt-6"> <div className="pt-4 flex justify-end gap-sm mt-6">
<button <Button type="button" label="Cancel" variant="secondary" onClick={() => setAssignUserId(null)} />
type="button" <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
type="submit" type="submit"
disabled={updateUserMut.isPending || !assignLocationId} label={updateUserMut.isPending ? 'Assigning…' : 'Assign User'}
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" isLoading={updateUserMut.isPending}
> isDisabled={updateUserMut.isPending || !assignLocationId}
{updateUserMut.isPending ? 'Assigning…' : 'Assign User'} />
</button>
</div> </div>
</form> </form>
</div> </div>
</div> </UsersModal>
)} )}
</div> </div>
); );

View File

@@ -13,6 +13,7 @@
*/ */
import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query';
import { useNotify } from '../components/Toasts';
import type { Row } from './fiestaApi'; import type { Row } from './fiestaApi';
import { loginRequest, matchTenantUser, buildAuthUser, type AuthUser } from './auth'; import { loginRequest, matchTenantUser, buildAuthUser, type AuthUser } from './auth';
import { import {
@@ -1021,6 +1022,10 @@ export function useFiestaGlobalProducts(opts: {
// ── Store Catalogue (Product Locations) ───────────────────────────────────── // ── Store Catalogue (Product Locations) ─────────────────────────────────────
export function useFiestaCreateProductLocation() { export function useFiestaCreateProductLocation() {
const qc = useQueryClient(); 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({ return useMutation({
mutationFn: (input: CreateProductLocationInput) => createProductLocation(input), mutationFn: (input: CreateProductLocationInput) => createProductLocation(input),
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
@@ -1030,7 +1035,7 @@ export function useFiestaCreateProductLocation() {
qc.invalidateQueries({ queryKey: ['fiesta', 'stockStatement'] }); qc.invalidateQueries({ queryKey: ['fiesta', 'stockStatement'] });
}, },
onError: (error: any) => { onError: (error: any) => {
alert(`Error adding product: ${error.message}`); notifyFail(error, 'Could not add this product.');
}, },
}); });
} }