conversion done with astryx
This commit is contained in:
9
.vscode/css_custom_data.json
vendored
Normal file
9
.vscode/css_custom_data.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": 1.1,
|
||||
"atDirectives": [
|
||||
{
|
||||
"name": "@theme",
|
||||
"description": "Tailwind CSS v4 theme configuration."
|
||||
}
|
||||
]
|
||||
}
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"css.lint.unknownAtRules": "ignore"
|
||||
"css.lint.unknownAtRules": "ignore",
|
||||
"css.customData": [".vscode/css_custom_data.json"]
|
||||
}
|
||||
|
||||
377
src/App.tsx
377
src/App.tsx
@@ -9,33 +9,18 @@ import PosStaffPanelAstryx from './components/spike/PosStaffPanelAstryx'; // PHA
|
||||
import ThemeShowcase from './components/spike/ThemeShowcase'; // PHASE 2 gate — throwaway
|
||||
import ConsoleUiProbe from './components/spike/ConsoleUiProbe'; // PHASE 3 verification — throwaway
|
||||
import {
|
||||
Network,
|
||||
Truck,
|
||||
Sliders,
|
||||
Calendar,
|
||||
FileCheck,
|
||||
Building,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
ShieldCheck,
|
||||
Send,
|
||||
HelpCircle,
|
||||
Database,
|
||||
ArrowRight,
|
||||
X,
|
||||
Search,
|
||||
Plus,
|
||||
MapPin,
|
||||
Phone,
|
||||
Activity,
|
||||
TrendingUp,
|
||||
Award,
|
||||
// Passed by reference into the Header's `icon` slot rather than rendered as
|
||||
// JSX here, which is why a JSX-only usage scan misses them.
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
Store,
|
||||
TrendingUp,
|
||||
Settings,
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Box
|
||||
Truck
|
||||
} from 'lucide-react';
|
||||
|
||||
import { MainSection } from './types';
|
||||
@@ -43,7 +28,6 @@ import {
|
||||
useFiestaTenantLocations,
|
||||
useFiestaLocationSummary,
|
||||
useFiestaUpdateLocation,
|
||||
useFiestaCreateLocation,
|
||||
useFiestaOrderSummary,
|
||||
} from './services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr, num as fnum } from './services/fiestaApi';
|
||||
@@ -60,11 +44,18 @@ import PosErrorBoundary from './components/PosErrorBoundary';
|
||||
import LoginView from './components/LoginView';
|
||||
import UserStorePage from './components/UserStorePage';
|
||||
import SuperAdminPage from './components/SuperAdminPage';
|
||||
import AwaitingApi from './components/AwaitingApi';
|
||||
import ComparisonModal from './components/ComparisonModal';
|
||||
import CatalogueBrowser from './components/CatalogueBrowser';
|
||||
import type { AuthUser } from './services/auth';
|
||||
import ragulStoreCover from './assets/images/store_front_view_1780299351800.png';
|
||||
import { useNotify } from './components/Toasts';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Switch } from '@astryxdesign/core/Switch';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Spinner } from '@astryxdesign/core/Spinner';
|
||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||
|
||||
const AUTH_STORAGE_KEY = 'nearledaily.auth';
|
||||
|
||||
@@ -115,6 +106,9 @@ export default function App() {
|
||||
// Navigation indicators states
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
// PHASE 6 — replaces the last four alert() calls in the app. A native alert
|
||||
// blocks the whole browser until dismissed; a toast does not.
|
||||
const { notifyOk } = useNotify();
|
||||
|
||||
// Derive current section from URL path, default to 'dashboard'
|
||||
const pathParts = location.pathname.split('/');
|
||||
@@ -143,9 +137,10 @@ export default function App() {
|
||||
const tenantId = authUser?.tenantid || FIESTA_TENANT_ID;
|
||||
|
||||
// ── Live data for the secondary sections (Fiesta) ─────────────────────────
|
||||
// Stores ← tenant locations + per-location order summary (seeded into local
|
||||
// state so the "Add Store" handler keeps working). Users management now lives
|
||||
// under Settings → Users & Access (see UsersPanel).
|
||||
// Stores ← tenant locations + per-location order summary, held in local state
|
||||
// so the per-card status toggle can update a row optimistically before the
|
||||
// refetch lands. Users management lives under Settings → Users & Access
|
||||
// (see UsersPanel); outlet creation lives under Settings → Outlets.
|
||||
const locationsQ = useFiestaTenantLocations(tenantId);
|
||||
const locSummaryQ = useFiestaLocationSummary(tenantId);
|
||||
const updateLocation = useFiestaUpdateLocation();
|
||||
@@ -238,41 +233,19 @@ export default function App() {
|
||||
}
|
||||
}, [locationsQ.data, locSummaryQ.data]);
|
||||
|
||||
// Secondary sub-sections modals triggers
|
||||
const [showAddStoreModal, setShowAddStoreModal] = useState(false);
|
||||
|
||||
// New forms states
|
||||
const [newStore, setNewStore] = useState({ name: '', zone: '', lead: '', sales: '₹1,50,000' });
|
||||
|
||||
// Form submission handles for secondary sections
|
||||
const handleCreateStore = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newStore.name || !newStore.zone || !newStore.lead) {
|
||||
alert('Kindly fill store metadata completely.');
|
||||
return;
|
||||
}
|
||||
setStoresList([...storesList, {
|
||||
locationid: 10000 + Math.floor(Math.random() * 9000),
|
||||
name: newStore.name,
|
||||
zone: newStore.zone,
|
||||
deliveries: 0,
|
||||
sales: '0 orders',
|
||||
orders: 0,
|
||||
staff: newStore.lead,
|
||||
color: 'emerald',
|
||||
status: 'Active'
|
||||
}]);
|
||||
setShowAddStoreModal(false);
|
||||
setNewStore({ name: '', zone: '', lead: '', sales: '₹1,50,000' });
|
||||
alert(`Node outlet "${newStore.name}" commissioned to live operations feed successfully.`);
|
||||
};
|
||||
|
||||
// Calendar Event Modal state
|
||||
const [showCalendarModal, setShowCalendarModal] = useState(false);
|
||||
// The "Add Store" and "Scheduled Reports Calendar" modals that used to live
|
||||
// here are gone, along with their state and `handleCreateStore`. Nothing ever
|
||||
// opened them: `setShowAddStoreModal(true)` and `setShowCalendarModal(true)`
|
||||
// were called nowhere in the codebase, so both were permanently false and the
|
||||
// markup was unreachable. `handleCreateStore` was reachable only through one
|
||||
// of them, and it never wrote to the backend — it pushed a row into local
|
||||
// `storesList` with a random `locationid`, which the next refetch discarded.
|
||||
// Real outlet creation is Settings → Outlets → Add Store Branch, which goes
|
||||
// through AdminConsole and the createlocation endpoint.
|
||||
|
||||
// Callback action triggers
|
||||
const handleHelp = () => {
|
||||
alert('nearledaily User Manual & Documentation Center linked successfully. Contact Coimbatore regional IT hub desk for urgent escalations.');
|
||||
notifyOk('Contact the Coimbatore regional IT hub desk for urgent escalations.');
|
||||
};
|
||||
|
||||
const handleLogout = () => setAuthUser(null);
|
||||
@@ -302,86 +275,65 @@ export default function App() {
|
||||
{/* ── Search & Filter Toolbar ── */}
|
||||
<div className="flex flex-col lg:flex-row items-center gap-3 bg-white p-1.5 rounded-2xl border border-slate-200/70 shadow-sm mb-8 w-full">
|
||||
{/* Search Input */}
|
||||
<div className="relative flex-1 w-full group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-purple-600 transition-colors">
|
||||
<Search className="w-5 h-5" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
<div className="flex-1 w-full">
|
||||
<TextInput
|
||||
label="Search stores"
|
||||
isLabelHidden
|
||||
placeholder="Search store by name, zone, or manager..."
|
||||
value={storesSearch}
|
||||
onChange={(e) => setStoresSearch(e.target.value)}
|
||||
className="w-full pl-11 pr-12 py-3 bg-transparent border-none text-sm font-semibold text-slate-800 placeholder-slate-400 focus:outline-none focus:ring-0 transition-all duration-300"
|
||||
onChange={(v) => setStoresSearch(v)}
|
||||
startIcon={<Search className="w-4 h-4" />}
|
||||
hasClear
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
{storesSearch && (
|
||||
<button
|
||||
onClick={() => setStoresSearch('')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vertical Divider (Hidden on Mobile) */}
|
||||
<div className="hidden lg:block w-px h-8 bg-slate-200" />
|
||||
|
||||
{/* Filter Segmented Control */}
|
||||
<div className="flex items-center p-1 bg-slate-50/80 rounded-xl border border-slate-200/50 w-full lg:w-auto shrink-0">
|
||||
<button
|
||||
onClick={() => setStoresFilter('ALL')}
|
||||
className={`flex-1 lg:flex-none px-5 py-2 rounded-lg text-xs font-bold transition-all duration-300 flex items-center justify-center gap-2 ${
|
||||
storesFilter === 'ALL'
|
||||
? 'bg-white text-slate-800 shadow-[0_1px_3px_rgba(0,0,0,0.1)] ring-1 ring-slate-900/5'
|
||||
: 'text-slate-500 hover:text-slate-700 hover:bg-white/50'
|
||||
}`}
|
||||
{/* PHASE 6 — the three filter buttons were a hand-built segmented
|
||||
control: they looked like one but announced as three unrelated
|
||||
buttons with no active state. The counts move into the labels so
|
||||
each option's accessible name carries its own number. */}
|
||||
<div className="w-full lg:w-auto shrink-0">
|
||||
<SegmentedControl
|
||||
label="Filter stores by status"
|
||||
size="sm"
|
||||
value={storesFilter}
|
||||
onChange={(v) => setStoresFilter(v as typeof storesFilter)}
|
||||
>
|
||||
All Nodes
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full font-bold ${storesFilter === 'ALL' ? 'bg-slate-100 text-slate-700' : 'bg-slate-200/50 text-slate-400'}`}>
|
||||
{storesList.length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStoresFilter('ACTIVE')}
|
||||
className={`flex-1 lg:flex-none px-5 py-2 rounded-lg text-xs font-bold transition-all duration-300 flex items-center justify-center gap-2 ${
|
||||
storesFilter === 'ACTIVE'
|
||||
? 'bg-white text-emerald-600 shadow-[0_1px_3px_rgba(0,0,0,0.1)] ring-1 ring-emerald-500/20'
|
||||
: 'text-slate-500 hover:text-emerald-600 hover:bg-emerald-50/50'
|
||||
}`}
|
||||
>
|
||||
Active
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full font-bold ${storesFilter === 'ACTIVE' ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-200/50 text-slate-400'}`}>
|
||||
{storesList.filter(s => s.status.toLowerCase() === 'active').length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStoresFilter('INACTIVE')}
|
||||
className={`flex-1 lg:flex-none px-5 py-2 rounded-lg text-xs font-bold transition-all duration-300 flex items-center justify-center gap-2 ${
|
||||
storesFilter === 'INACTIVE'
|
||||
? 'bg-white text-slate-700 shadow-[0_1px_3px_rgba(0,0,0,0.1)] ring-1 ring-slate-900/5'
|
||||
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-200/50'
|
||||
}`}
|
||||
>
|
||||
Inactive
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full font-bold ${storesFilter === 'INACTIVE' ? 'bg-slate-100 text-slate-700' : 'bg-slate-200/50 text-slate-400'}`}>
|
||||
{storesList.filter(s => s.status.toLowerCase() !== 'active').length}
|
||||
</span>
|
||||
</button>
|
||||
<SegmentedControlItem value="ALL" label={`All Nodes (${storesList.length})`} />
|
||||
<SegmentedControlItem
|
||||
value="ACTIVE"
|
||||
label={`Active (${storesList.filter(s => s.status.toLowerCase() === 'active').length})`}
|
||||
/>
|
||||
<SegmentedControlItem
|
||||
value="INACTIVE"
|
||||
label={`Inactive (${storesList.filter(s => s.status.toLowerCase() !== 'active').length})`}
|
||||
/>
|
||||
</SegmentedControl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty States */}
|
||||
{filteredStoresList.length === 0 && (
|
||||
<div className="text-center py-12 text-zinc-400 text-xs border border-dashed border-[#e2e8f0] rounded-xl bg-white p-8">
|
||||
<div className="py-8">
|
||||
{locationsQ.isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<div className="w-6 h-6 border-2 border-[#662582] border-t-transparent rounded-full animate-spin" />
|
||||
<span>Loading live store locations…</span>
|
||||
</div>
|
||||
<div className="flex justify-center"><Spinner label="Loading live store locations…" /></div>
|
||||
) : (
|
||||
<span>No store locations found matching your filter criteria.</span>
|
||||
<EmptyState
|
||||
icon={<Building size={26} />}
|
||||
title="No store locations found"
|
||||
description={
|
||||
storesSearch || storesFilter !== 'ALL'
|
||||
? 'Nothing matches your search or filter.'
|
||||
: 'Commission your first outlet to start taking orders.'
|
||||
}
|
||||
actions={
|
||||
storesSearch || storesFilter !== 'ALL'
|
||||
? <Button label="Clear filters" variant="secondary" onClick={() => { setStoresSearch(''); setStoresFilter('ALL'); }} />
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -505,34 +457,41 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
<IconButton
|
||||
label={`Contact node lead ${st.staff}`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Phone className="w-3.5 h-3.5" />}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
alert(`Routing communications channel directly to manager ${st.staff}...`);
|
||||
notifyOk(`Routing communications channel directly to manager ${st.staff}…`);
|
||||
}}
|
||||
className="p-1.5 rounded-lg bg-white border border-zinc-200 hover:border-[#662582] hover:text-[#662582] text-zinc-500 transition-colors shadow-sm"
|
||||
title="Communicate with Node Lead"
|
||||
>
|
||||
<Phone className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Card footer - enter console */}
|
||||
<div className="flex items-center justify-between text-[10px] font-bold text-[#662582] mt-4 pt-3 border-t border-zinc-100/80">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const newStatus = st.status.toLowerCase() === 'active' ? 'Inactive' : 'Active';
|
||||
setStoresList(stores => stores.map(s => s.locationid === st.locationid ? { ...s, status: newStatus } : s));
|
||||
if (st.locationid) {
|
||||
updateLocation.mutate({ locationid: st.locationid, status: newStatus.toLowerCase() });
|
||||
}
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${st.status.toLowerCase() === 'active' ? 'bg-emerald-500' : 'bg-zinc-300'}`}
|
||||
title={`Toggle store to ${st.status.toLowerCase() === 'active' ? 'Inactive' : 'Active'}`}
|
||||
>
|
||||
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${st.status.toLowerCase() === 'active' ? 'translate-x-5' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
{/* A real Switch. This was a <button> hand-drawn to look
|
||||
like a toggle: it announced as a button with no on/off
|
||||
state, and its only explanation was a `title`. Switch
|
||||
exposes the state via a checkbox role, so the store's
|
||||
live/inactive status is finally audible — on a control
|
||||
that takes a shop offline. */}
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
label={`${st.name} is ${st.status.toLowerCase() === 'active' ? 'active' : 'inactive'}`}
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={st.status.toLowerCase() === 'active'}
|
||||
onChange={(checked) => {
|
||||
const newStatus = checked ? 'Active' : 'Inactive';
|
||||
setStoresList(stores => stores.map(s => s.locationid === st.locationid ? { ...s, status: newStatus } : s));
|
||||
if (st.locationid) {
|
||||
updateLocation.mutate({ locationid: st.locationid, status: newStatus.toLowerCase() });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1 uppercase tracking-wider">
|
||||
<span>Enter Terminal Console</span>
|
||||
@@ -709,138 +668,6 @@ export default function App() {
|
||||
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
</Routes>
|
||||
|
||||
{/* CALENDAR SCHEDULER DIALOG MODAL */}
|
||||
{showCalendarModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowCalendarModal(false); }}
|
||||
>
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl w-full max-w-[24rem] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-xs font-sans cursor-default">
|
||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0">
|
||||
<h4 className="font-bold text-[#0f172a] flex items-center gap-xs">
|
||||
<Calendar size={15} className="text-[#662582]" />
|
||||
Scheduled Reports Calendar
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => setShowCalendarModal(false)}
|
||||
className="p-1 hover:bg-zinc-200 rounded-full text-zinc-400 cursor-pointer transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-md space-y-md overflow-y-auto flex-1">
|
||||
<p className="text-zinc-500 leading-relaxed font-semibold">
|
||||
Automated compliance summaries will appear here once scheduled-report exports are available.
|
||||
</p>
|
||||
|
||||
<AwaitingApi label="Scheduled reports" api="[R13]" />
|
||||
</div>
|
||||
|
||||
<div className="p-sm bg-[#f8fafc] border-t border-[#e2e8f0] flex justify-end shrink-0">
|
||||
<button
|
||||
onClick={() => setShowCalendarModal(false)}
|
||||
className="px-4 py-2 bg-[#0f172a] text-white rounded-lg font-semibold hover:bg-zinc-800 cursor-pointer shadow-sm transition-colors"
|
||||
>
|
||||
Close Calendar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CREATE NEW STORE MODAL */}
|
||||
{showAddStoreModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowAddStoreModal(false); }}
|
||||
>
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl w-full max-w-[24rem] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-xs font-sans cursor-default">
|
||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0">
|
||||
<h4 className="font-bold text-[#0f172a] flex items-center gap-xs">
|
||||
<Building size={15} className="text-[#662582]" />
|
||||
Commission New Regional Store Node
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => setShowAddStoreModal(false)}
|
||||
className="p-1 hover:bg-zinc-200 rounded-full text-zinc-400 cursor-pointer transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateStore} className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<div className="p-md space-y-md overflow-y-auto flex-1">
|
||||
<div className="space-y-sm">
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">STORE OUTLET NAME (*)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. RS Puram Super Hub"
|
||||
value={newStore.name}
|
||||
onChange={(e) => setNewStore({ ...newStore, name: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">LOCAL ZONE AREA (*)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Coimbatore North"
|
||||
value={newStore.zone}
|
||||
onChange={(e) => setNewStore({ ...newStore, zone: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">OUTLET TEAM MANAGER (*)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Sridhar Sundaram"
|
||||
value={newStore.lead}
|
||||
onChange={(e) => setNewStore({ ...newStore, lead: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">ESTIMATED INITIAL REVENUE</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="₹1,50,000"
|
||||
value={newStore.sales}
|
||||
onChange={(e) => setNewStore({ ...newStore, sales: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-md border-t border-[#f1f5f9] flex justify-end gap-sm bg-[#f8fafc] shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddStoreModal(false)}
|
||||
className="px-4 py-2 border border-[#e2e8f0] rounded-lg font-semibold text-zinc-500 hover:bg-zinc-50 cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-[#662582] text-white rounded-lg font-bold hover:bg-purple-800 cursor-pointer shadow-sm"
|
||||
>
|
||||
Create Outlet Node
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ComparisonModal />
|
||||
</>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,6 @@
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
Truck, Clock, CheckCircle2, XCircle, Calendar, Sun, Sunset, Moon, Layers, UserCheck, MapPin, Phone, Package, Loader2, X, Bike,
|
||||
} from 'lucide-react';
|
||||
@@ -22,9 +21,22 @@ import { useFiestaDeliverySummary, useFiestaDeliveries, useFiestaRiders, useFies
|
||||
import { FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, RIDER_MESSAGES, RiderNotReachableError, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||
import { shortTime } from '../services/fiestaMappers';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
import { DateInput } from '@astryxdesign/core/DateInput';
|
||||
import type { ISODateString } from '@astryxdesign/core/utils';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Heading } from '@astryxdesign/core/Heading';
|
||||
import { Spinner } from '@astryxdesign/core/Spinner';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Card as ShellCard } from './consoleUi';
|
||||
import {
|
||||
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
|
||||
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge,
|
||||
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
|
||||
} from '@astryxdesign/core/Table';
|
||||
import {
|
||||
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar,
|
||||
DELIVERY_STATUS, statusColor, BRAND, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge,
|
||||
} from './consoleUi';
|
||||
|
||||
interface DeliveriesViewProps { searchQuery?: string; locationid?: number; tenantId?: number; applocationid?: number; date?: string; }
|
||||
@@ -144,9 +156,25 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<input type="date" value={fromdate} max={todate} onChange={(e) => setFromdate(e.target.value)} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||
{/* Both were unlabelled `<input type="date">`; the min/max
|
||||
cross-guard between them is preserved. */}
|
||||
<DateInput
|
||||
label="From date"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={fromdate as ISODateString}
|
||||
max={todate as ISODateString}
|
||||
onChange={(v) => { if (v) setFromdate(v); }}
|
||||
/>
|
||||
<span style={{ color: TEXT_3 }}>→</span>
|
||||
<input type="date" value={todate} min={fromdate} onChange={(e) => setTodate(e.target.value)} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||
<DateInput
|
||||
label="To date"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={todate as ISODateString}
|
||||
min={fromdate as ISODateString}
|
||||
onChange={(v) => { if (v) setTodate(v); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FilterBar>
|
||||
@@ -166,17 +194,17 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
|
||||
</FilterBar>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border overflow-hidden" style={{ borderColor: BORDER }}>
|
||||
<ShellCard className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" style={{ minWidth: 1240 }}>
|
||||
<thead>
|
||||
<tr>{['S.No', 'Tenant', 'Order ID', 'Pickup', 'Delivery', 'Rider', 'KMS', 'Amount', 'Status', 'Notes', 'Action'].map((h, i) => (<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>))}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table style={{ minWidth: 1240 }}>
|
||||
<TableHeader>
|
||||
<TableRow>{['S.No', 'Tenant', 'Order ID', 'Pickup', 'Delivery', 'Rider', 'KMS', 'Amount', 'Status', 'Notes', 'Action'].map((h, i) => (<TableHeaderCell key={i}>{h}</TableHeaderCell>))}</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{deliveriesQ.isLoading ? (
|
||||
<tr><td colSpan={11} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}><span className="inline-flex items-center gap-2 text-xs font-semibold"><Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading deliveries…</span></td></tr>
|
||||
<TableRow><TableCell colSpan={11}><div className="py-8 flex justify-center"><Spinner label="Loading deliveries…" /></div></TableCell></TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<tr><td colSpan={11} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No {status} deliveries in this wave. Try another status, wave, or date.</td></tr>
|
||||
<TableRow><TableCell colSpan={11}><EmptyState isCompact icon={<Truck size={26} />} title={`No ${status} deliveries in this wave`} description="Try another status, wave, or date." /></TableCell></TableRow>
|
||||
) : (
|
||||
rows.map((r, i) => {
|
||||
const st = fstr(r.orderstatus).toLowerCase();
|
||||
@@ -192,64 +220,65 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
|
||||
const dropAddr = fstr(r.deliveryaddress) || fstr(r.deliverylocation) || fstr(r.deliverysuburb);
|
||||
const notes = fstr(r.ordernotes) || fstr(r.notes);
|
||||
return (
|
||||
<tr key={fstr(r.deliveryid) || fstr(r.orderid) || i} className="transition-colors align-top" style={{ borderBottom: `1px solid ${DIVIDER}` }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
|
||||
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<TableRow key={fstr(r.deliveryid) || fstr(r.orderid) || i}>
|
||||
<TableCell><span className="font-mono" style={{ color: TEXT_3 }}>{i + 1}</span></TableCell>
|
||||
<TableCell>
|
||||
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{tenant || '—'}</p>
|
||||
{fstr(r.tenantcity) && <p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.tenantcity)}</p>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`}</p>
|
||||
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.assigntime || r.deliverydate)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{pickupName || pickupAddr || '—'}</p>
|
||||
<p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{pickupName ? pickupAddr : ''}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{dropName || dropAddr || '—'}</p>
|
||||
<p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{dropName ? dropAddr : ''}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{rider ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="rounded-full flex items-center justify-center shrink-0" style={{ width: 26, height: 26, background: soft('#8b5cf6'), color: '#8b5cf6' }}><Bike size={13} /></span>
|
||||
<span className="font-bold text-[12px] truncate max-w-[100px]" style={{ color: TEXT }}>{rider}</span>
|
||||
</span>
|
||||
) : <span className="text-[11px] italic" style={{ color: TEXT_3 }}>Unassigned</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<MetricPill color="#ef4444" minWidth={64}>{kms ? kms.toFixed(1) : '—'}</MetricPill>
|
||||
{actualKms > 0 && <MetricPill color="#10b981" minWidth={64}>{actualKms.toFixed(1)}</MetricPill>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{charge > 0 && <MetricPill color="#ef4444" minWidth={72}>₹{charge.toLocaleString('en-IN')}</MetricPill>}
|
||||
{amt > 0 && <MetricPill color="#10b981" minWidth={72}>₹{amt.toLocaleString('en-IN')}</MetricPill>}
|
||||
{charge === 0 && amt === 0 && <span style={{ color: TEXT_3 }}>—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
</TableCell>
|
||||
<TableCell><StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} /></TableCell>
|
||||
<TableCell>
|
||||
<p className="text-[11px] truncate max-w-[160px]" style={{ color: notes ? TEXT_2 : TEXT_3 }} title={notes}>{notes || '—'}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right">
|
||||
<button onClick={() => setDetailRow(r)} className="rounded-full font-extrabold cursor-pointer" style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>View</button>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">
|
||||
<Button label="View" aria-label={`View delivery ${fstr(r.orderid) || fstr(r.deliveryid)}`} variant="secondary" size="sm" onClick={() => setDetailRow(r)} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="px-4 py-2.5 border-t text-[10px] font-bold uppercase tracking-wider" style={{ borderColor: BORDER, background: SURFACE_ALT, color: TEXT_2 }}>
|
||||
{rows.length} {status} · {BATCHES.find((b) => b.id === batch)?.label} wave
|
||||
</div>
|
||||
</div>
|
||||
</ShellCard>
|
||||
|
||||
{detailRow && <DeliveryDetailModal row={detailRow} riders={ridersQ.data ?? []} onClose={() => setDetailRow(null)} />}
|
||||
</div>
|
||||
@@ -329,24 +358,27 @@ function RiderActions({ row, riders }: { row: Row; riders: Row[] }) {
|
||||
|
||||
{canChange ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={picked}
|
||||
onChange={(e) => setPicked(Number(e.target.value))}
|
||||
disabled={busy}
|
||||
className="rounded-full font-bold text-[11px] outline-none cursor-pointer disabled:opacity-50"
|
||||
style={{ padding: '6px 10px', border: `1px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 200 }}
|
||||
>
|
||||
<option value={0}>{options.length ? 'Change rider…' : 'No riders on duty'}</option>
|
||||
{options.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
<button
|
||||
{/* The "no riders on duty" wording carried real information, so it
|
||||
moves to `placeholder` rather than being flattened away. */}
|
||||
<div className="min-w-[180px] max-w-[220px]">
|
||||
<Selector
|
||||
label="Change rider"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={picked ? String(picked) : ''}
|
||||
onChange={(v) => setPicked(Number(v) || 0)}
|
||||
isDisabled={busy}
|
||||
placeholder={options.length ? 'Change rider…' : 'No riders on duty'}
|
||||
options={options.map((o) => ({ value: String(o.id), label: o.label }))}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
label={changeMut.isPending ? 'Moving…' : 'Change'}
|
||||
size="sm"
|
||||
isLoading={changeMut.isPending}
|
||||
isDisabled={!picked || busy}
|
||||
onClick={handleChange}
|
||||
disabled={!picked || busy}
|
||||
className="rounded-full font-extrabold cursor-pointer text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{ padding: '6px 14px', fontSize: 11, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}
|
||||
>
|
||||
{changeMut.isPending ? 'Moving…' : 'Change'}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] font-medium" style={{ color: TEXT_3 }}>
|
||||
@@ -358,25 +390,27 @@ function RiderActions({ row, riders }: { row: Row; riders: Row[] }) {
|
||||
|
||||
{canNotify && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2 pt-2" style={{ borderTop: `1px solid ${DIVIDER}` }}>
|
||||
<button
|
||||
<Button
|
||||
label="Notify rider"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isDisabled={busy}
|
||||
onClick={() => push(currentToken, RIDER_MESSAGES.reminder)}
|
||||
disabled={busy}
|
||||
className="rounded-full font-bold cursor-pointer disabled:opacity-40"
|
||||
style={{ padding: '5px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}
|
||||
>
|
||||
Notify rider
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
push(currentToken, RIDER_MESSAGES.cancelled(fstr(row.orderid) || `DLV-${deliveryid}`), { type: 'cancel' }, 'Cancellation sent')
|
||||
}
|
||||
disabled={busy}
|
||||
title="Tells the rider app to drop this delivery"
|
||||
className="rounded-full font-bold cursor-pointer disabled:opacity-40"
|
||||
style={{ padding: '5px 12px', fontSize: 11, color: '#b91c1c', background: '#fef2f2', border: '1px solid #fecaca' }}
|
||||
>
|
||||
Send cancellation
|
||||
</button>
|
||||
/>
|
||||
{/* Destructive: this tells the rider app to drop the delivery. It was
|
||||
styled red but rendered as an ordinary button, and its only
|
||||
explanation was a `title` — which a disabled control never shows. */}
|
||||
<span title="Tells the rider app to drop this delivery">
|
||||
<Button
|
||||
label="Send cancellation"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isDisabled={busy}
|
||||
onClick={() =>
|
||||
push(currentToken, RIDER_MESSAGES.cancelled(fstr(row.orderid) || `DLV-${deliveryid}`), { type: 'cancel' }, 'Cancellation sent')
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -401,15 +435,21 @@ function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]
|
||||
{ label: 'Picked', field: 'pickuptime' }, { label: 'Delivered', field: 'deliverytime' },
|
||||
];
|
||||
|
||||
// Portal to <body> so `fixed inset-0` is viewport-relative even when an ancestor
|
||||
// in the view tree is transformed/blurred (otherwise the panel collapses).
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="bg-white max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
|
||||
<div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)` }} />
|
||||
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Truck size={16} style={{ color: BRAND }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}</h4>
|
||||
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button>
|
||||
// PHASE 6 — the portal and the `width: min(32rem, 92vw)` are gone with the
|
||||
// overlay. Both existed for one reason: `fixed inset-0` resolves against the
|
||||
// nearest transformed/filtered ancestor rather than the viewport, and this
|
||||
// page sits inside several, so the panel used to collapse. Dialog renders in
|
||||
// the browser's top layer, which no ancestor can contain.
|
||||
return (
|
||||
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
|
||||
<div className="flex flex-col max-h-[90vh]">
|
||||
<div className="p-4 flex justify-between items-center shrink-0">
|
||||
<Heading level={3}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Truck size={16} style={{ color: BRAND }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}
|
||||
</span>
|
||||
</Heading>
|
||||
<IconButton label="Close delivery details" variant="ghost" size="sm" icon={<X size={16} />} onClick={onClose} />
|
||||
</div>
|
||||
<div className="p-4 space-y-4 overflow-y-auto flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -451,11 +491,10 @@ function DeliveryDetailModal({ row, riders, onClose }: { row: Row; riders: Row[]
|
||||
</div>
|
||||
<RiderActions row={row} riders={riders} />
|
||||
</div>
|
||||
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}>Close</button>
|
||||
<div className="p-3 flex justify-end shrink-0">
|
||||
<Button label="Close" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,15 @@ import {
|
||||
} from './consoleUi';
|
||||
|
||||
import SalesRevenueReport from './SalesRevenueReport';
|
||||
import { DateInput } from '@astryxdesign/core/DateInput';
|
||||
import type { ISODateString } from '@astryxdesign/core/utils';
|
||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Card as ShellCard } from './consoleUi';
|
||||
import {
|
||||
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
|
||||
} from '@astryxdesign/core/Table';
|
||||
|
||||
type ReportTab = 'orders-summary' | 'riders-summary';
|
||||
const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> = [
|
||||
@@ -55,19 +64,17 @@ export default function DeliveryReportsView({ searchQuery = '', tenantId = FIEST
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in duration-300">
|
||||
<div className="flex items-center gap-4 border-b border-slate-200/70 pb-2 mb-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`pb-2 text-sm font-bold border-b-2 transition-colors ${activeTab === 'overview' ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
||||
{/* PHASE 6 — two underline-styled buttons standing in for a tab bar; the
|
||||
active one was marked by a border colour and nothing else. */}
|
||||
<div className="border-b border-slate-200/70 pb-2 mb-4">
|
||||
<SegmentedControl
|
||||
label="Report section"
|
||||
value={activeTab}
|
||||
onChange={(v) => setActiveTab(v as typeof activeTab)}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('sales_revenue')}
|
||||
className={`pb-2 text-sm font-bold border-b-2 transition-colors ${activeTab === 'sales_revenue' ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
Sales & Revenue
|
||||
</button>
|
||||
<SegmentedControlItem value="overview" label="Overview" />
|
||||
<SegmentedControlItem value="sales_revenue" label="Sales & Revenue" />
|
||||
</SegmentedControl>
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' ? (
|
||||
@@ -93,10 +100,28 @@ export default function DeliveryReportsView({ searchQuery = '', tenantId = FIEST
|
||||
<React.Fragment key={p.key}><Pill active={activePreset === p.key} color={BRAND} onClick={() => { setFromdate(p.from); setTodate(p.to); }}>{p.label}</Pill></React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
{/* Both were unlabelled `<input type="date">` — a screen reader
|
||||
announced "date" twice with no way to tell the ends of the range
|
||||
apart. The min/max cross-guard is preserved. */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<input type="date" value={fromdate} max={todate} onChange={(e) => setFromdate(e.target.value)} className="rounded-full outline-none font-semibold w-28 sm:w-auto" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||
<DateInput
|
||||
label="From date"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={fromdate as ISODateString}
|
||||
max={todate as ISODateString}
|
||||
onChange={(v) => { if (v) setFromdate(v); }}
|
||||
/>
|
||||
<span style={{ color: TEXT_3 }}>→</span>
|
||||
<input type="date" value={todate} min={fromdate} max={ymd(today)} onChange={(e) => setTodate(e.target.value)} className="rounded-full outline-none font-semibold w-28 sm:w-auto" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||
<DateInput
|
||||
label="To date"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={todate as ISODateString}
|
||||
min={fromdate as ISODateString}
|
||||
max={ymd(today) as ISODateString}
|
||||
onChange={(v) => { if (v) setTodate(v); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,8 +227,21 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{/* Revenue */}
|
||||
<button onClick={() => setMetric('revenue')} className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${metric === 'revenue' ? 'border-indigo-200 bg-white shadow-[0_8px_24px_rgba(99,102,241,0.12)] ring-1 ring-indigo-500/20 z-10' : 'border-slate-200/70 bg-white hover:border-indigo-200 hover:shadow-[0_8px_24px_rgba(99,102,241,0.06)] opacity-70 hover:opacity-100'}`}>
|
||||
{/* Revenue.
|
||||
These two stay native <button> — a deliberate exception, and the only
|
||||
one on this page. They are large stat tiles carrying a metric value,
|
||||
a tooltip and an icon; Astryx's ToggleButton is Button-shaped and
|
||||
would fight the layout, and SelectableCard was ruled out in PosView
|
||||
because it renders no role and no aria-checked and strips both if you
|
||||
pass them. What was actually missing is `aria-pressed`: the selected
|
||||
metric lived only in a border colour. That is added here, which is
|
||||
exactly what ToggleButton emits internally. */}
|
||||
<button
|
||||
onClick={() => setMetric('revenue')}
|
||||
aria-pressed={metric === 'revenue'}
|
||||
aria-label="Chart by revenue"
|
||||
className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${metric === 'revenue' ? 'border-indigo-200 bg-white shadow-[0_8px_24px_rgba(99,102,241,0.12)] ring-1 ring-indigo-500/20 z-10' : 'border-slate-200/70 bg-white hover:border-indigo-200 hover:shadow-[0_8px_24px_rgba(99,102,241,0.06)] opacity-70 hover:opacity-100'}`}
|
||||
>
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<Tooltip content="Total revenue collected across all orders (excluding cancelled) in this period." position="bottom">
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Revenue</span>
|
||||
@@ -221,7 +259,12 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
|
||||
</button>
|
||||
|
||||
{/* Orders */}
|
||||
<button onClick={() => setMetric('orders')} className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${metric === 'orders' ? 'border-sky-200 bg-white shadow-[0_8px_24px_rgba(14,165,233,0.12)] ring-1 ring-sky-500/20 z-10' : 'border-slate-200/70 bg-white hover:border-sky-200 hover:shadow-[0_8px_24px_rgba(14,165,233,0.06)] opacity-70 hover:opacity-100'}`}>
|
||||
<button
|
||||
onClick={() => setMetric('orders')}
|
||||
aria-pressed={metric === 'orders'}
|
||||
aria-label="Chart by order count"
|
||||
className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${metric === 'orders' ? 'border-sky-200 bg-white shadow-[0_8px_24px_rgba(14,165,233,0.12)] ring-1 ring-sky-500/20 z-10' : 'border-slate-200/70 bg-white hover:border-sky-200 hover:shadow-[0_8px_24px_rgba(14,165,233,0.06)] opacity-70 hover:opacity-100'}`}
|
||||
>
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<Tooltip content="Total number of orders placed in this period, regardless of current status." position="bottom">
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Total Orders</span>
|
||||
@@ -311,53 +354,61 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<ShellCard className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||
<tr>
|
||||
<th className="px-5 py-4 w-12">#</th>
|
||||
<th className="px-5 py-4">Outlet</th>
|
||||
<th className="px-5 py-4 text-right">Revenue</th>
|
||||
<th className="px-5 py-4 text-right">AOV</th>
|
||||
<th className="px-5 py-4 text-right">Orders</th>
|
||||
<th className="px-5 py-4 text-right">Fulfillment</th>
|
||||
<th className="px-5 py-4 text-right">Cancel Rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{q.isLoading || revenueQ.isLoading ? <tr><td colSpan={7} className="px-5 py-6"><div className="space-y-3"><Skeleton className="h-5 w-full" /><Skeleton className="h-5 w-full opacity-70" /><Skeleton className="h-5 w-full opacity-40" /></div></td></tr>
|
||||
: rows.length === 0 ? <tr><td colSpan={7} className="px-5 py-12 text-center text-slate-500 font-medium">No outlet data available.</td></tr>
|
||||
: rows.map((r, i) => {
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>#</TableHeaderCell>
|
||||
<TableHeaderCell>Outlet</TableHeaderCell>
|
||||
<TableHeaderCell>Revenue</TableHeaderCell>
|
||||
<TableHeaderCell>AOV</TableHeaderCell>
|
||||
<TableHeaderCell>Orders</TableHeaderCell>
|
||||
<TableHeaderCell>Fulfillment</TableHeaderCell>
|
||||
<TableHeaderCell>Cancel Rate</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{q.isLoading || revenueQ.isLoading ? (
|
||||
<TableRow><TableCell colSpan={7}>
|
||||
<div className="space-y-3 py-3"><Skeleton className="h-5 w-full" /><Skeleton className="h-5 w-full opacity-70" /><Skeleton className="h-5 w-full opacity-40" /></div>
|
||||
</TableCell></TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7}>
|
||||
<EmptyState isCompact icon={<Store size={26} />} title="No outlet data available" description="Nothing recorded for this period." />
|
||||
</TableCell></TableRow>
|
||||
) : rows.map((r, i) => {
|
||||
const rowRev = locationRevenueMap.get(fnum(r.locationid)) ?? 0;
|
||||
const rowAov = r.total > 0 ? rowRev / r.total : 0;
|
||||
const rowFulfill = r.total > 0 ? (r.delivered / r.total) * 100 : 0;
|
||||
const rowCancel = r.total > 0 ? (r.cancelled / r.total) * 100 : 0;
|
||||
return (
|
||||
<tr key={r.locationid || i} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-5 py-3 text-slate-400 font-medium">{i + 1}</td>
|
||||
<td className="px-5 py-3 font-semibold text-slate-900">{r.locationname || `Location ${r.locationid}`}</td>
|
||||
<td className="px-5 py-3 text-right font-bold text-indigo-600">₹{rowRev.toLocaleString('en-IN')}</td>
|
||||
<td className="px-5 py-3 text-right text-slate-600 font-medium">₹{Math.round(rowAov).toLocaleString('en-IN')}</td>
|
||||
<td className="px-5 py-3 text-right font-bold text-slate-900">{r.total.toLocaleString('en-IN')}</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<TableRow key={r.locationid || i}>
|
||||
<TableCell><span className="text-slate-400 font-medium">{i + 1}</span></TableCell>
|
||||
<TableCell><span className="font-semibold">{r.locationname || `Location ${r.locationid}`}</span></TableCell>
|
||||
<TableCell><div className="text-right font-bold text-indigo-600">₹{rowRev.toLocaleString('en-IN')}</div></TableCell>
|
||||
<TableCell><div className="text-right font-medium">₹{Math.round(rowAov).toLocaleString('en-IN')}</div></TableCell>
|
||||
<TableCell><div className="text-right font-bold">{r.total.toLocaleString('en-IN')}</div></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-xs font-bold text-emerald-600">{rowFulfill.toFixed(0)}%</span>
|
||||
<div className="w-16 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-emerald-500 rounded-full" style={{ width: `${rowFulfill}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold ${rowCancel > 10 ? 'bg-rose-100 text-rose-700' : 'bg-slate-100 text-slate-600'}`}>
|
||||
{rowCancel.toFixed(1)}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{/* Over 10% was rose, otherwise grey — that is a
|
||||
threshold, so it reads as error vs neutral. */}
|
||||
<div className="text-right">
|
||||
<Badge variant={rowCancel > 10 ? 'error' : 'neutral'} label={`${rowCancel.toFixed(1)}%`} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{rows.length > 0 && (
|
||||
<div className="bg-slate-50 border-t border-slate-200 px-5 py-3 flex items-center gap-4 text-sm font-medium">
|
||||
@@ -368,7 +419,7 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
|
||||
<span className="text-amber-600 font-bold">{totals.pending} pending</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ShellCard>
|
||||
|
||||
{/* Insights Bento */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Route, ShoppingBag, Truck, MapPin, Calendar, ChevronLeft, ChevronRight, Store, Users } from 'lucide-react';
|
||||
import { Route, ShoppingBag, Truck, MapPin, ChevronLeft, ChevronRight, Store, Users } from 'lucide-react';
|
||||
import DispatchView from './DispatchView';
|
||||
import OrdersView from './OrdersView';
|
||||
import DeliveriesView from './DeliveriesView';
|
||||
@@ -12,14 +12,17 @@ import { useFiestaDeliveries, useFiestaTenantLocations } from '../services/fiest
|
||||
import { FIESTA_TENANT_ID, ymd, num as fnum, str as fstr } from '../services/fiestaApi';
|
||||
import type { Row } from '../services/fiestaApi';
|
||||
import './DispatchView.css'; // For #hdr and date-chip styles
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { DateInput } from '@astryxdesign/core/DateInput';
|
||||
import type { ISODateString } from '@astryxdesign/core/utils';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||
|
||||
interface DispatchHubViewProps {
|
||||
locationid?: number;
|
||||
tenantId?: number;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_ID }: DispatchHubViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<'map' | 'orders' | 'deliveries'>('map');
|
||||
@@ -55,8 +58,6 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
|
||||
}, [locationsQ.data, locationid]);
|
||||
|
||||
const isToday = date === ymd(today);
|
||||
const dateObj = new Date(`${date}T00:00:00`);
|
||||
const prettyDate = `${WEEKDAYS[dateObj.getDay()]}, ${dateObj.getDate()} ${MONTHS[dateObj.getMonth()]}`;
|
||||
const shiftDate = (delta: number) => {
|
||||
const d = new Date(`${date}T00:00:00`);
|
||||
d.setDate(d.getDate() + delta);
|
||||
@@ -103,86 +104,75 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className={`flex items-center bg-slate-50 border border-slate-200 rounded-xl p-0.5 sm:p-1 shadow-sm ${isToday ? 'ring-1 ring-blue-500/20' : ''}`}>
|
||||
<button className="p-1 sm:p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-200/50 transition-colors" onClick={() => shiftDate(-1)} title="Previous day">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<div className="relative flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-0.5 sm:py-1">
|
||||
<Calendar size={13} className="text-slate-400 hidden sm:block" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[8px] sm:text-[9px] font-bold text-slate-400 uppercase tracking-widest leading-none mb-0.5 flex items-center gap-1">
|
||||
Date {isToday && <span className="text-[7px] sm:text-[8px] bg-blue-100 text-blue-600 px-1 rounded-sm">TODAY</span>}
|
||||
</span>
|
||||
<span className="text-[11px] sm:text-xs font-bold text-slate-700 leading-none">{prettyDate}</span>
|
||||
</div>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
max={ymd(today)}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
||||
aria-label="Pick date"
|
||||
{/* PHASE 6 — the date stepper.
|
||||
The picker used to be an `opacity-0` <input type="date"> stretched
|
||||
over a custom display, so the same date was rendered twice: once
|
||||
as `prettyDate` for the eye and once inside an invisible control.
|
||||
DateInput formats and announces it itself, so the duplicate
|
||||
display goes and the TODAY marker becomes a Badge beside it.
|
||||
The two chevrons had only a `title` — no accessible name. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<IconButton
|
||||
label="Previous day"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<ChevronLeft size={16} />}
|
||||
onClick={() => shiftDate(-1)}
|
||||
/>
|
||||
<div className="w-40 sm:w-44">
|
||||
<DateInput
|
||||
label="Dispatch date"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={date as ISODateString}
|
||||
max={ymd(today) as ISODateString}
|
||||
onChange={(v) => { if (v) setDate(v); }}
|
||||
/>
|
||||
</div>
|
||||
<button className="p-1 sm:p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-200/50 transition-colors disabled:opacity-30 disabled:hover:bg-transparent" onClick={() => shiftDate(1)} disabled={isToday} title="Next day">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
{isToday && <Badge variant="info" label="Today" />}
|
||||
<IconButton
|
||||
label="Next day"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<ChevronRight size={16} />}
|
||||
isDisabled={isToday}
|
||||
onClick={() => shiftDate(1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Unified Tab Row ── */}
|
||||
<div className="flex items-center px-3 sm:px-6 py-2.5 border-b border-slate-200 shadow-sm z-10 relative overflow-x-auto custom-scrollbar no-scrollbar touch-scrolling">
|
||||
<div className="flex items-center p-1 bg-slate-100/80 rounded-lg border border-slate-200/80 shadow-inner shrink-0">
|
||||
<button
|
||||
onClick={() => setActiveTab('map')}
|
||||
className={`flex items-center gap-1.5 px-3 sm:px-4 py-1 sm:py-1.5 text-[10px] sm:text-[11px] font-extrabold rounded-md transition-all duration-300 uppercase tracking-wide ${
|
||||
activeTab === 'map' ? 'bg-white text-purple-700 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
||||
}`}
|
||||
{/* Both rows were hand-built segmented controls — three and two
|
||||
buttons styled to look like one control, announcing as unrelated
|
||||
buttons with the selection carried only in background colour. */}
|
||||
<div className="shrink-0">
|
||||
<SegmentedControl
|
||||
label="Dispatch view"
|
||||
size="sm"
|
||||
value={activeTab}
|
||||
onChange={(v) => setActiveTab(v as typeof activeTab)}
|
||||
>
|
||||
<Route size={13} className={activeTab === 'map' ? 'text-purple-600' : 'text-slate-400'} /> Map
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('orders')}
|
||||
className={`flex items-center gap-1.5 px-3 sm:px-4 py-1 sm:py-1.5 text-[10px] sm:text-[11px] font-extrabold rounded-md transition-all duration-300 uppercase tracking-wide ${
|
||||
activeTab === 'orders' ? 'bg-white text-purple-700 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
||||
}`}
|
||||
>
|
||||
<ShoppingBag size={13} className={activeTab === 'orders' ? 'text-purple-600' : 'text-slate-400'} /> Orders
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('deliveries')}
|
||||
className={`flex items-center gap-1.5 px-3 sm:px-4 py-1 sm:py-1.5 text-[10px] sm:text-[11px] font-extrabold rounded-md transition-all duration-300 uppercase tracking-wide ${
|
||||
activeTab === 'deliveries' ? 'bg-white text-purple-700 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
||||
}`}
|
||||
>
|
||||
<Truck size={13} className={activeTab === 'deliveries' ? 'text-purple-600' : 'text-slate-400'} /> Deliveries
|
||||
</button>
|
||||
<SegmentedControlItem value="map" label="Map" icon={<Route size={13} />} />
|
||||
<SegmentedControlItem value="orders" label="Orders" icon={<ShoppingBag size={13} />} />
|
||||
<SegmentedControlItem value="deliveries" label="Deliveries" icon={<Truck size={13} />} />
|
||||
</SegmentedControl>
|
||||
</div>
|
||||
|
||||
{/* Sub-tabs for Map View — only meaningful on the Map tab; they used to
|
||||
stay visible (and inert) on Orders and Deliveries. */}
|
||||
{activeTab === 'map' && (
|
||||
<div className="flex items-center p-1 ml-3 sm:ml-4 bg-slate-100/80 rounded-lg border border-slate-200/80 shadow-inner shrink-0">
|
||||
<button
|
||||
onClick={() => setMapViewMode('stores')}
|
||||
className={`flex items-center gap-1.5 px-3 sm:px-4 py-1 sm:py-1.5 text-[10px] sm:text-[11px] font-extrabold rounded-md transition-all duration-300 tracking-wide ${
|
||||
mapViewMode === 'stores' ? 'bg-white text-slate-800 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
||||
}`}
|
||||
<div className="ml-3 sm:ml-4 shrink-0">
|
||||
<SegmentedControl
|
||||
label="Map grouping"
|
||||
size="sm"
|
||||
value={mapViewMode}
|
||||
onChange={(v) => setMapViewMode(v as typeof mapViewMode)}
|
||||
>
|
||||
<Store size={13} className={mapViewMode === 'stores' ? 'text-blue-500' : 'text-slate-400'} /> By Store
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setMapViewMode('customers')}
|
||||
className={`flex items-center gap-1.5 px-3 sm:px-4 py-1 sm:py-1.5 text-[10px] sm:text-[11px] font-extrabold rounded-md transition-all duration-300 tracking-wide ${
|
||||
mapViewMode === 'customers' ? 'bg-white text-slate-800 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
||||
}`}
|
||||
>
|
||||
<Users size={13} className={mapViewMode === 'customers' ? 'text-blue-500' : 'text-slate-400'} /> By Customer
|
||||
</button>
|
||||
<SegmentedControlItem value="stores" label="By Store" icon={<Store size={13} />} />
|
||||
<SegmentedControlItem value="customers" label="By Customer" icon={<Users size={13} />} />
|
||||
</SegmentedControl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,39 +4,20 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Layers,
|
||||
Search,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
TrendingUp,
|
||||
Sparkles,
|
||||
Check,
|
||||
Package,
|
||||
ChevronRight,
|
||||
TrendingDown,
|
||||
Trash2,
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
Tag,
|
||||
UploadCloud,
|
||||
FileSpreadsheet,
|
||||
Palette,
|
||||
Info,
|
||||
X,
|
||||
Server,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CheckCircle,
|
||||
ShoppingCart,
|
||||
Inbox,
|
||||
Store,
|
||||
Activity,
|
||||
Award,
|
||||
Filter,
|
||||
Box,
|
||||
ArrowLeft
|
||||
} from 'lucide-react';
|
||||
import { ProductMatrixItem } from '../types';
|
||||
@@ -55,7 +36,21 @@ import { useStoreCatalogue, usePriceEverywhere, isPublishedItem } from '../servi
|
||||
import { CardCarousel, ProductGallery } from './CatalogueGallery';
|
||||
import BulkCartDrawer from './BulkCartDrawer';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi';
|
||||
import { SlideDrawer, Skeleton, Card as ShellCard } from './consoleUi';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { DateInput } from '@astryxdesign/core/DateInput';
|
||||
import type { ISODateString } from '@astryxdesign/core/utils';
|
||||
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Heading } from '@astryxdesign/core/Heading';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import {
|
||||
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
|
||||
} from '@astryxdesign/core/Table';
|
||||
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
||||
import CatalogueDetailPanel from './CatalogueDetailPanel';
|
||||
import { useNotify } from './Toasts';
|
||||
@@ -86,7 +81,6 @@ export default function InventoryView({
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showCatalogueModal, setShowCatalogueModal] = useState(false);
|
||||
const [showOfflineSales, setShowOfflineSales] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ── Live stock across every outlet (Fiesta) ───────────────────────────────
|
||||
// This page is the admin's command surface. The GLOBAL CATALOG is the deduped
|
||||
@@ -432,48 +426,46 @@ export default function InventoryView({
|
||||
<div className="mb-2 z-40 relative">
|
||||
{/* Small card metrics grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
{/* PHASE 6 — four `bg-white` slabs onto Astryx Cards. The icon chips
|
||||
keep their hues: those are category identity, not surface. */}
|
||||
{/* Card 1: Total SKUs */}
|
||||
<div className="bg-white rounded-lg p-2.5 px-3 border border-slate-200 shadow-sm hover:border-purple-200 transition-all group">
|
||||
<ShellCard className="p-2.5 px-3 hover:border-purple-200 transition-all group">
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-[9px] text-slate-500 uppercase tracking-widest font-bold">Total SKUs</span>
|
||||
<span className="text-[9px] uppercase tracking-widest font-bold opacity-60">Total SKUs</span>
|
||||
<div className="p-1 rounded-md bg-purple-50 text-purple-600 group-hover:scale-110 transition-transform">
|
||||
<Package className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5">
|
||||
<h3 className="text-base font-extrabold tracking-tight font-mono text-slate-900">
|
||||
{products.length}
|
||||
</h3>
|
||||
<p className="text-[9px] text-slate-400 font-semibold mt-0.5">Master catalogue</p>
|
||||
<h3 className="text-base font-extrabold tracking-tight font-mono">{products.length}</h3>
|
||||
<p className="text-[9px] font-semibold mt-0.5 opacity-50">Master catalogue</p>
|
||||
</div>
|
||||
</div>
|
||||
</ShellCard>
|
||||
|
||||
{/* Card 2: Synced Outlets */}
|
||||
<div className="bg-white rounded-lg p-2.5 px-3 border border-slate-200 shadow-sm hover:border-indigo-200 transition-all group">
|
||||
<ShellCard className="p-2.5 px-3 hover:border-indigo-200 transition-all group">
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-[9px] text-slate-500 uppercase tracking-widest font-bold">Active Outlets</span>
|
||||
<span className="text-[9px] uppercase tracking-widest font-bold opacity-60">Active Outlets</span>
|
||||
<div className="p-1 rounded-md bg-indigo-50 text-indigo-600 group-hover:scale-110 transition-transform">
|
||||
<Layers className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5">
|
||||
<h3 className="text-base font-extrabold tracking-tight font-mono text-slate-900">
|
||||
{locations.length}
|
||||
</h3>
|
||||
<p className="text-[9px] text-slate-400 font-semibold mt-0.5">Synced locations</p>
|
||||
<h3 className="text-base font-extrabold tracking-tight font-mono">{locations.length}</h3>
|
||||
<p className="text-[9px] font-semibold mt-0.5 opacity-50">Synced locations</p>
|
||||
</div>
|
||||
</div>
|
||||
</ShellCard>
|
||||
|
||||
{/* Card 3: Total On-Hand Volume */}
|
||||
<div className="bg-white rounded-lg p-2.5 px-3 border border-slate-200 shadow-sm hover:border-emerald-200 transition-all group">
|
||||
<ShellCard className="p-2.5 px-3 hover:border-emerald-200 transition-all group">
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-[9px] text-slate-500 uppercase tracking-widest font-bold">Total Stock</span>
|
||||
<span className="text-[9px] uppercase tracking-widest font-bold opacity-60">Total Stock</span>
|
||||
<div className="p-1 rounded-md bg-emerald-50 text-emerald-600 group-hover:scale-110 transition-transform">
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5">
|
||||
<h3 className="text-base font-extrabold tracking-tight font-mono text-slate-900">
|
||||
<h3 className="text-base font-extrabold tracking-tight font-mono">
|
||||
{storesStock.reduce((total, store) => {
|
||||
return total + (store.rows || []).reduce((subTotal, r) => {
|
||||
const inv = stockRowToInventory(r, store.locationname);
|
||||
@@ -481,25 +473,25 @@ export default function InventoryView({
|
||||
}, 0);
|
||||
}, 0).toLocaleString('en-IN')}
|
||||
</h3>
|
||||
<p className="text-[9px] text-slate-400 font-semibold mt-0.5">Units on hand</p>
|
||||
<p className="text-[9px] font-semibold mt-0.5 opacity-50">Units on hand</p>
|
||||
</div>
|
||||
</div>
|
||||
</ShellCard>
|
||||
|
||||
{/* Card 4: Catalog Health */}
|
||||
<div className="bg-white rounded-lg p-2.5 px-3 border border-slate-200 shadow-sm hover:border-amber-200 transition-all group">
|
||||
<ShellCard className="p-2.5 px-3 hover:border-amber-200 transition-all group">
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-[9px] text-slate-500 uppercase tracking-widest font-bold">Catalogue Sync Ratio</span>
|
||||
<span className="text-[9px] uppercase tracking-widest font-bold opacity-60">Catalogue Sync Ratio</span>
|
||||
<div className="p-1 rounded-md bg-amber-50 text-amber-600 group-hover:scale-110 transition-transform">
|
||||
<ShieldCheck className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5">
|
||||
<h3 className="text-base font-extrabold tracking-tight font-mono text-slate-900">
|
||||
<h3 className="text-base font-extrabold tracking-tight font-mono">
|
||||
{products.length > 0 ? `${Math.round((products.filter(p => p.verified).length / products.length) * 100)}%` : '100%'}
|
||||
</h3>
|
||||
<p className="text-[9px] text-slate-400 font-semibold mt-0.5">Active Portfolio</p>
|
||||
<p className="text-[9px] font-semibold mt-0.5 opacity-50">Active Portfolio</p>
|
||||
</div>
|
||||
</div>
|
||||
</ShellCard>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -509,47 +501,47 @@ export default function InventoryView({
|
||||
<div className="flex flex-col xl:flex-row gap-6 mt-2 flex-1 min-h-0 overflow-hidden pb-4">
|
||||
{isLocalSidebarOpen && (
|
||||
<div className="w-full xl:w-52 shrink-0 flex flex-col gap-5 pr-2 pb-2">
|
||||
<div className="bg-white border border-slate-200 shadow-sm overflow-hidden flex-1 min-h-0 flex flex-col">
|
||||
<div className="p-4 bg-slate-50 border-b border-slate-100 shrink-0">
|
||||
<h3 className="font-bold text-sm text-slate-800 flex items-center gap-2">
|
||||
<ShellCard className="overflow-hidden flex-1 min-h-0 flex flex-col">
|
||||
<div className="p-4 border-b border-slate-100 shrink-0">
|
||||
<h3 className="font-bold text-sm flex items-center gap-2">
|
||||
<Filter size={16} className="text-[#662582]" /> Filter Product
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4 space-y-5 overflow-y-auto custom-scrollbar">
|
||||
<div>
|
||||
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
||||
<h3 className="text-[11px] font-extrabold uppercase tracking-widest mb-3 flex items-center gap-1.5 opacity-60">
|
||||
<Layers size={14} className="text-[#662582]" /> Categories
|
||||
</h3>
|
||||
{/* PHASE 6 — the category filter was an `appearance-none` box
|
||||
with a floating tick icon: a real checkbox stripped of its
|
||||
focus ring and its label association, so it was invisible to
|
||||
keyboard focus and announced nothing. CheckboxInput's label
|
||||
IS the accessible name. */}
|
||||
<div className="space-y-1.5">
|
||||
{categories.map((cat) => (
|
||||
<label key={cat} className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-50 cursor-pointer group transition-colors">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCategories.includes(cat)}
|
||||
onChange={() => toggleCategory(cat)}
|
||||
className="peer appearance-none w-5 h-5 border-2 border-slate-300 rounded-lg checked:border-[#662582] checked:bg-[#662582] transition-colors cursor-pointer"
|
||||
/>
|
||||
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
|
||||
</div>
|
||||
<span className={`text-xs font-semibold select-none transition-colors ${selectedCategories.includes(cat) ? 'text-[#662582]' : 'text-slate-600 group-hover:text-slate-900'}`}>
|
||||
{cat}
|
||||
</span>
|
||||
</label>
|
||||
<div key={cat} className="p-2 rounded-xl">
|
||||
<CheckboxInput
|
||||
label={cat}
|
||||
value={selectedCategories.includes(cat)}
|
||||
onChange={() => toggleCategory(cat)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(selectedCategories.length > 0 || localSearch) && (
|
||||
<button
|
||||
onClick={() => { setSelectedCategories([]); setLocalSearch(''); }}
|
||||
className="w-full py-2 bg-slate-100 text-slate-600 rounded-xl text-[10px] font-bold uppercase tracking-wider hover:bg-slate-200 transition-colors cursor-pointer border-none mt-4"
|
||||
>
|
||||
Clear All Filters
|
||||
</button>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
label="Clear all filters"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => { setSelectedCategories([]); setLocalSearch(''); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ShellCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -572,54 +564,53 @@ export default function InventoryView({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto shrink-0">
|
||||
<button
|
||||
<Button
|
||||
label="Filter"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Filter size={12} />}
|
||||
onClick={() => setIsLocalSidebarOpen(!isLocalSidebarOpen)}
|
||||
className="flex items-center gap-1.5 bg-white border border-slate-200 px-2.5 py-1.5 rounded-lg text-[10px] font-bold hover:bg-slate-50 transition-colors text-slate-700 shadow-sm cursor-pointer h-[32px] whitespace-nowrap"
|
||||
>
|
||||
<Filter size={12} /> Filter
|
||||
</button>
|
||||
|
||||
<div className="relative flex-1 sm:flex-none w-full sm:w-32 xl:w-48 h-[32px]">
|
||||
<input
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<div className="flex-1 sm:flex-none w-full sm:w-32 xl:w-48">
|
||||
<TextInput
|
||||
label="Search catalogue"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
placeholder="Search..."
|
||||
value={localSearch}
|
||||
onChange={(e) => setLocalSearch(e.target.value)}
|
||||
className="w-full h-full pl-8 pr-3 py-1.5 bg-white border border-slate-200 rounded-lg text-[10px] text-slate-800 placeholder-slate-400 focus:outline-none focus:border-[#662582] transition-all shadow-sm font-medium"
|
||||
onChange={(v) => setLocalSearch(v)}
|
||||
startIcon={<Search size={14} />}
|
||||
hasClear
|
||||
/>
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 w-3.5 h-3.5" />
|
||||
{localSearch && (
|
||||
<button
|
||||
onClick={() => setLocalSearch('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 bg-transparent border-none cursor-pointer"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('requests')}
|
||||
className="flex items-center gap-1.5 bg-slate-50 hover:bg-slate-100 border border-slate-200 text-slate-700 px-2.5 py-1.5 rounded-lg text-[10px] font-bold transition-colors shadow-sm cursor-pointer h-[32px] whitespace-nowrap"
|
||||
>
|
||||
<Inbox size={12} /> Stock Requests
|
||||
{storeRequests.length > 0 && (
|
||||
<span className="ml-1 bg-rose-500 text-white text-[8px] px-1 py-0.5 rounded-sm leading-none">{storeRequests.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
{/* The request count moves out of the button's text and into a
|
||||
Badge — a Button's label is its accessible name, and
|
||||
"Stock Requests 3" is not one. */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
label="Stock Requests"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Inbox size={12} />}
|
||||
onClick={() => setActiveTab('requests')}
|
||||
/>
|
||||
{storeRequests.length > 0 && <Badge variant="error" label={String(storeRequests.length)} />}
|
||||
</div>
|
||||
<Button
|
||||
label="Offline Sales"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<UploadCloud size={12} />}
|
||||
onClick={() => setShowOfflineSales(true)}
|
||||
title="Upload an Excel sheet of counter sales to deduct stock and record the revenue"
|
||||
className="flex items-center gap-1.5 bg-slate-50 hover:bg-slate-100 border border-slate-200 text-slate-700 px-2.5 py-1.5 rounded-lg text-[10px] font-bold transition-colors shadow-sm cursor-pointer h-[32px] whitespace-nowrap"
|
||||
>
|
||||
<UploadCloud size={12} /> Offline Sales
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<Button
|
||||
label="Import Product"
|
||||
size="sm"
|
||||
icon={<Plus size={12} />}
|
||||
onClick={() => setActiveTab('global_catalogue')}
|
||||
className="flex items-center gap-1.5 bg-[#662582] hover:bg-purple-800 text-white px-2.5 py-1.5 rounded-lg text-[10px] font-bold transition-colors shadow-sm cursor-pointer h-[32px] whitespace-nowrap"
|
||||
>
|
||||
<Plus size={12} /> Import Product
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -640,7 +631,16 @@ export default function InventoryView({
|
||||
))}
|
||||
</div>
|
||||
) : filteredProducts.length === 0 ? (
|
||||
<div className="text-center py-xl text-zinc-400 text-xs font-bold">No catalogue products match your selection.</div>
|
||||
<EmptyState
|
||||
icon={<Package size={26} />}
|
||||
title="No catalogue products match your selection"
|
||||
description={localSearch || selectedCategories.length ? 'Try a different search or category.' : 'Import products to build the catalogue.'}
|
||||
actions={
|
||||
localSearch || selectedCategories.length
|
||||
? <Button label="Clear all filters" variant="secondary" onClick={() => { setSelectedCategories([]); setLocalSearch(''); }} />
|
||||
: <Button label="Import Product" icon={<Plus size={14} />} onClick={() => setActiveTab('global_catalogue')} />
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col xl:flex-row gap-6">
|
||||
{/* Left Side: Normal Catalogue */}
|
||||
@@ -688,35 +688,41 @@ export default function InventoryView({
|
||||
</div>
|
||||
|
||||
{isPublished ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); storeCat.remove(prod.id); }}
|
||||
title="Remove from store catalogue"
|
||||
className="w-full bg-emerald-50 hover:bg-rose-50 text-emerald-700 hover:text-rose-600 border border-emerald-200 hover:border-rose-200 px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<CheckCircle size={12} />
|
||||
Remove from Store
|
||||
</button>
|
||||
<Button
|
||||
label="Remove from Store"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<CheckCircle size={12} />}
|
||||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); storeCat.remove(prod.id); }}
|
||||
/>
|
||||
) : addingPriceProdId === prod.id ? (
|
||||
<div className="flex flex-col gap-1.5 p-2 bg-slate-50 border border-slate-200 rounded-none" onClick={(e) => e.stopPropagation()}>
|
||||
<label className="text-[9px] font-extrabold text-slate-600 uppercase tracking-widest text-center">
|
||||
Set Selling Price (₹)
|
||||
</label>
|
||||
<span className="text-[8px] font-bold text-slate-400 uppercase tracking-wider text-center -mt-1">
|
||||
<div className="flex flex-col gap-1.5 p-2 rounded-none" style={{ background: 'rgba(127,127,127,0.06)' }} onClick={(e) => e.stopPropagation()}>
|
||||
<span className="text-[8px] font-bold uppercase tracking-wider text-center opacity-60">
|
||||
{allLocationIds.length === 1 ? 'Your store' : `All ${allLocationIds.length} stores`}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={cardImportPrice}
|
||||
onChange={(e) => setCardImportPrice(e.target.value)}
|
||||
placeholder="e.g. 50"
|
||||
className="px-2 py-1 border border-slate-200 rounded-none text-[10px] font-bold focus:outline-none focus:border-[#662582]"
|
||||
autoFocus
|
||||
{/* NumberInput carries its own label, so the
|
||||
hand-rolled <label> above it is gone rather
|
||||
than announced twice. */}
|
||||
<NumberInput
|
||||
label="Set Selling Price (₹)"
|
||||
size="sm"
|
||||
value={cardImportPrice === '' ? null : Number(cardImportPrice)}
|
||||
onChange={(v) => setCardImportPrice(v == null ? '' : String(v))}
|
||||
min={0}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={(e) => { e.stopPropagation(); setAddingPriceProdId(null); }} className="flex-1 py-1 bg-white text-slate-500 border border-slate-200 text-[9px] font-bold">Cancel</button>
|
||||
<button
|
||||
disabled={!cardImportPrice || Number(cardImportPrice) <= 0 || publishMut.isPending}
|
||||
onClick={(e) => {
|
||||
<Button
|
||||
label="Cancel"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setAddingPriceProdId(null); }}
|
||||
/>
|
||||
<Button
|
||||
label="Confirm"
|
||||
size="sm"
|
||||
isLoading={publishMut.isPending}
|
||||
isDisabled={!cardImportPrice || Number(cardImportPrice) <= 0 || publishMut.isPending}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Prices AND publishes, in one server-side step.
|
||||
//
|
||||
@@ -748,24 +754,21 @@ export default function InventoryView({
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="flex-1 py-1 bg-[#662582] text-white text-[9px] font-bold disabled:opacity-50"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
<Button
|
||||
label="Add to Store"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Plus size={12} />}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCardImportPrice(String(prod.price || (prod.unitsSold > 0 ? Math.round(prod.revenue / prod.unitsSold) : 0)));
|
||||
setAddingPriceProdId(prod.id);
|
||||
setAddingPriceProdId(prod.id);
|
||||
}}
|
||||
className="w-full bg-[#f8fafc] hover:bg-[#662582] text-slate-600 hover:text-white border border-slate-200 hover:border-[#662582] px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
|
||||
title="Add to Store Catalogue"
|
||||
>
|
||||
<Plus size={12} /> Add to Store
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -783,13 +786,15 @@ export default function InventoryView({
|
||||
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar animate-in fade-in duration-300">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-end gap-4 mb-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
{/* Was a hand-drawn chevron <svg> in a bare button with only a
|
||||
`title` — no accessible name, and lucide's ArrowLeft was
|
||||
already imported. */}
|
||||
<IconButton
|
||||
label="Back to Catalogue"
|
||||
variant="ghost"
|
||||
icon={<ArrowLeft size={20} />}
|
||||
onClick={() => setActiveTab('catalog')}
|
||||
className="p-2 -ml-2 hover:bg-slate-100 rounded-xl transition-colors cursor-pointer text-slate-500 hover:text-slate-900"
|
||||
title="Back to Catalogue"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m15 18-6-6 6-6"/></svg>
|
||||
</button>
|
||||
/>
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-purple-50 to-indigo-50 border border-purple-100 flex items-center justify-center shadow-[0_2px_10px_-3px_rgba(102,37,130,0.15)] shrink-0">
|
||||
<Inbox size={24} className="text-[#662582]" strokeWidth={2.5} />
|
||||
</div>
|
||||
@@ -806,172 +811,160 @@ export default function InventoryView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PHASE 6 — both filters were `appearance-none` selects with a
|
||||
hand-drawn chevron layered on top and no label; the store one had
|
||||
a decorative icon absolutely positioned over the text. Selector
|
||||
supplies the chevron, the label and the keyboard behaviour. */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative group">
|
||||
<Store size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-hover:text-[#662582] transition-colors z-10" />
|
||||
<select
|
||||
<div className="w-48">
|
||||
<Selector
|
||||
label="Filter by store"
|
||||
isLabelHidden
|
||||
value={requestStoreFilter}
|
||||
onChange={(e) => setRequestStoreFilter(e.target.value)}
|
||||
className="appearance-none bg-white border border-slate-200 text-slate-700 pl-9 pr-10 py-2.5 rounded-xl text-sm font-semibold shadow-sm outline-none cursor-pointer hover:border-[#662582]/40 hover:shadow-md transition-all focus:border-[#662582] focus:ring-2 focus:ring-[#662582]/20"
|
||||
>
|
||||
<option value="All Stores">All Stores</option>
|
||||
{requestingStores.map(store => (
|
||||
<option key={store} value={store}>{store}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400 group-hover:text-[#662582] transition-colors">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
onChange={(v) => setRequestStoreFilter(v || 'All Stores')}
|
||||
startIcon={<Store size={14} />}
|
||||
options={['All Stores', ...requestingStores]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<select
|
||||
<div className="w-44">
|
||||
<Selector
|
||||
label="Filter by status"
|
||||
isLabelHidden
|
||||
value={requestStatusFilter}
|
||||
onChange={(e) => setRequestStatusFilter(e.target.value)}
|
||||
className="appearance-none bg-white border border-slate-200 text-slate-700 pl-4 pr-10 py-2.5 rounded-xl text-sm font-semibold shadow-sm outline-none cursor-pointer hover:border-[#662582]/40 hover:shadow-md transition-all focus:border-[#662582] focus:ring-2 focus:ring-[#662582]/20"
|
||||
>
|
||||
<option value="All Statuses">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="received">Received</option>
|
||||
</select>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400 group-hover:text-[#662582] transition-colors">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
onChange={(v) => setRequestStatusFilter(v || 'All Statuses')}
|
||||
options={[
|
||||
{ value: 'All Statuses', label: 'All Statuses' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
{ value: 'received', label: 'Received' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={requestDate}
|
||||
onChange={(e) => setRequestDate(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-200 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-[#662582] focus:border-[#662582] text-sm font-semibold text-slate-700 bg-white hover:border-[#662582]/40 hover:shadow-md transition-all"
|
||||
|
||||
<div className="w-44">
|
||||
<DateInput
|
||||
label="Request date"
|
||||
isLabelHidden
|
||||
value={requestDate as ISODateString}
|
||||
onChange={(v) => { if (v) setRequestDate(v); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{flattenedRequests.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-80 bg-gradient-to-b from-white to-slate-50 border border-slate-200/60 border-dashed rounded-2xl shadow-sm">
|
||||
<div className="w-20 h-20 rounded-full bg-slate-100/80 flex items-center justify-center mb-6 shadow-inner">
|
||||
<Inbox size={32} className="text-slate-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-700 tracking-tight">No Pending Requests</h3>
|
||||
<p className="text-sm font-medium text-slate-400 mt-2 text-center w-full max-w-[400px] leading-relaxed">
|
||||
You're all caught up! All store stock requests have been processed and there is currently no pending action required.
|
||||
</p>
|
||||
</div>
|
||||
<ShellCard className="py-20">
|
||||
<EmptyState
|
||||
icon={<Inbox size={32} />}
|
||||
title="No Pending Requests"
|
||||
description="You're all caught up! All store stock requests have been processed and there is currently no pending action required."
|
||||
/>
|
||||
</ShellCard>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] overflow-hidden">
|
||||
<ShellCard className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse" style={{ minWidth: 960 }}>
|
||||
<thead>
|
||||
<tr className="bg-slate-50/80 border-b border-slate-200">
|
||||
<Table style={{ minWidth: 960 }}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{['Requested At', 'Store', 'Product', 'Qty', 'Status', 'Resolved At', ''].map((h, i) => (
|
||||
<th key={i} className="px-5 py-4 text-[10px] font-black uppercase tracking-widest text-slate-500 whitespace-nowrap">{h}</th>
|
||||
<TableHeaderCell key={i}>{h}</TableHeaderCell>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{flattenedRequests.map((req, idx) => {
|
||||
const isApproved = req.pickData.status === 'Approved';
|
||||
const isRejected = req.pickData.status === 'Rejected';
|
||||
const isPending = req.pickData.status === 'Pending';
|
||||
const isCancelled = req.pickData.status === 'Cancelled';
|
||||
const isReceived = req.pickData.status === 'Received';
|
||||
|
||||
// The five-way class ladder collapses to a Badge variant.
|
||||
// 'Received' was indigo and 'Cancelled' slate; neither has a
|
||||
// Badge equivalent, so they map to the nearest semantic
|
||||
// meaning (info / neutral) rather than an invented hue.
|
||||
const statusVariant =
|
||||
req.pickData.status === 'Received' ? 'info' :
|
||||
req.pickData.status === 'Approved' ? 'success' :
|
||||
req.pickData.status === 'Rejected' ? 'error' :
|
||||
req.pickData.status === 'Cancelled' ? 'neutral' :
|
||||
'warning';
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={`${req.locationid}-${req.productid}-${idx}`}
|
||||
className="transition-all duration-200 hover:bg-slate-50/80 group cursor-pointer"
|
||||
<TableRow
|
||||
key={`${req.locationid}-${req.requestid}`}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => setSelectedRequest(req)}
|
||||
>
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-slate-900">
|
||||
<TableCell>
|
||||
<div className="flex flex-col whitespace-nowrap">
|
||||
<span className="text-sm font-semibold">
|
||||
{req.pickData.requestedAt ? new Date(req.pickData.requestedAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
<span className="text-xs font-medium opacity-60">
|
||||
{req.pickData.requestedAt ? new Date(req.pickData.requestedAt).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-indigo-50/50 text-indigo-700 border border-indigo-100/50 transition-colors group-hover:bg-indigo-100/50">
|
||||
<Store size={14} className="text-indigo-500" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-flex items-center gap-2 whitespace-nowrap">
|
||||
<Store size={14} className="text-indigo-500" />
|
||||
<span className="font-bold text-xs">{req.locationname}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-10 h-10 rounded-xl overflow-hidden border border-slate-200 bg-white shadow-sm shrink-0 group-hover:shadow transition-shadow">
|
||||
<img src={req.product?.image} alt={req.product?.name} className="w-full h-full object-cover" />
|
||||
<div className="relative w-10 h-10 rounded-xl overflow-hidden shrink-0">
|
||||
<img src={req.product?.image} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-sm text-slate-900 truncate group-hover:text-indigo-700 transition-colors">{req.product?.name}</p>
|
||||
<p className="text-xs font-medium text-slate-500 truncate mt-0.5">{req.product?.sku}</p>
|
||||
<p className="font-bold text-sm truncate">{req.product?.name}</p>
|
||||
<p className="text-xs font-medium truncate mt-0.5 opacity-60">{req.product?.sku}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 rounded-md bg-slate-100 text-slate-700 font-bold text-sm border border-slate-200/60">
|
||||
{req.pickData.qty || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${
|
||||
isReceived ? 'bg-indigo-50 text-indigo-700 border-indigo-200' :
|
||||
isApproved ? 'bg-emerald-50 text-emerald-700 border-emerald-200' :
|
||||
isRejected ? 'bg-rose-50 text-rose-700 border-rose-200' :
|
||||
isCancelled ? 'bg-slate-50 text-slate-700 border-slate-200' :
|
||||
'bg-amber-50 text-amber-700 border-amber-200'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${
|
||||
isReceived ? 'bg-indigo-500' :
|
||||
isApproved ? 'bg-emerald-500' :
|
||||
isRejected ? 'bg-rose-500' :
|
||||
isCancelled ? 'bg-slate-400' :
|
||||
'bg-amber-500 animate-pulse'
|
||||
}`} />
|
||||
{req.pickData.status || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-bold text-sm tabular-nums">{req.pickData.qty || '—'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant} label={req.pickData.status || '—'} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs font-medium whitespace-nowrap opacity-60">
|
||||
{req.pickData.resolvedAt ? new Date(req.pickData.resolvedAt).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 whitespace-nowrap text-right">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isPending ? (
|
||||
<div className="flex items-center justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Approved'); }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold text-emerald-700 bg-emerald-50 hover:bg-emerald-100 hover:text-emerald-800 border border-emerald-200 transition-all active:scale-95"
|
||||
title="Approve Request"
|
||||
>
|
||||
<CheckCircle size={14} /> Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Rejected'); }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold text-rose-700 bg-rose-50 hover:bg-rose-100 hover:text-rose-800 border border-rose-200 transition-all active:scale-95"
|
||||
title="Reject Request"
|
||||
>
|
||||
<X size={14} /> Reject
|
||||
</button>
|
||||
// The `opacity-0 group-hover:opacity-100` reveal is
|
||||
// gone: it hid the only two actions on the row from
|
||||
// anyone not using a mouse, and a keyboard user
|
||||
// could tab to an invisible button.
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
label="Approve"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<CheckCircle size={14} />}
|
||||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Approved'); }}
|
||||
/>
|
||||
<Button
|
||||
label="Reject"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
icon={<X size={14} />}
|
||||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Rejected'); }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] font-bold text-slate-300 uppercase tracking-widest">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-right block opacity-40">
|
||||
Processed
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</ShellCard>
|
||||
)}
|
||||
</div>
|
||||
) : activeTab === 'global_catalogue' ? (
|
||||
@@ -1070,23 +1063,29 @@ export default function InventoryView({
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
<label className="text-[10px] font-extrabold text-slate-500 uppercase tracking-wider">Selling Price (₹)</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 font-bold pointer-events-none">₹</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={importPrice}
|
||||
onChange={(e) => setImportPrice(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="e.g. 50"
|
||||
className="w-full pl-7 pr-3 py-2.5 border border-slate-200 rounded-xl text-sm font-bold text-slate-900 focus:outline-none focus:ring-2 focus:ring-[#662582]/20 focus:border-[#662582] bg-white transition-all shadow-xs"
|
||||
/>
|
||||
</div>
|
||||
{/* The ₹ was an absolutely-positioned span overlapping the
|
||||
field's padding; NumberInput carries it as a real prefix
|
||||
so it cannot drift out of alignment. */}
|
||||
<NumberInput
|
||||
label="Selling Price (₹)"
|
||||
value={importPrice === '' ? null : Number(importPrice)}
|
||||
onChange={(v) => setImportPrice(v == null ? '' : String(v))}
|
||||
step={0.01}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={!importPrice || Number(importPrice) <= 0 || isPricing}
|
||||
<Button
|
||||
label={
|
||||
isPricing
|
||||
? 'Saving…'
|
||||
: storeCat.has(selectedAdminProduct.id)
|
||||
? 'Save Price to All Stores'
|
||||
: 'Save Price & Publish to All Stores'
|
||||
}
|
||||
icon={<CheckCircle size={16} strokeWidth={2.5} />}
|
||||
isLoading={isPricing}
|
||||
isDisabled={!importPrice || Number(importPrice) <= 0 || isPricing}
|
||||
onClick={() => {
|
||||
// Publishing is a listing/pricing action, never a stock delivery — real
|
||||
// quantity only lands via Mark as Received. priceEverywhere() sends
|
||||
@@ -1105,26 +1104,18 @@ export default function InventoryView({
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl text-xs font-extrabold transition-all bg-[#662582] hover:bg-[#531e6a] text-white shadow-md disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<CheckCircle size={16} strokeWidth={2.5} />
|
||||
{isPricing
|
||||
? 'Saving…'
|
||||
: storeCat.has(selectedAdminProduct.id)
|
||||
? 'Save Price to All Stores'
|
||||
: 'Save Price & Publish to All Stores'}
|
||||
</button>
|
||||
<p className="text-[10px] font-semibold text-slate-500 text-center -mt-1">
|
||||
/>
|
||||
<p className="text-[10px] font-semibold text-center -mt-1 opacity-60">
|
||||
Applies to {allLocationIds.length === 1 ? 'your store' : `all ${allLocationIds.length} stores`}
|
||||
</p>
|
||||
|
||||
{storeCat.has(selectedAdminProduct.id) && (
|
||||
<button
|
||||
onClick={() => storeCat.remove(selectedAdminProduct.id)}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-white text-rose-600 border border-slate-200 font-bold text-xs transition-colors hover:bg-slate-50 cursor-pointer mt-1"
|
||||
>
|
||||
<X size={14} strokeWidth={2} /> Remove from Store Catalogue
|
||||
</button>
|
||||
<Button
|
||||
label="Remove from Store Catalogue"
|
||||
variant="destructive"
|
||||
icon={<X size={14} strokeWidth={2} />}
|
||||
onClick={() => storeCat.remove(selectedAdminProduct.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1201,24 +1192,21 @@ export default function InventoryView({
|
||||
|
||||
{isPending ? (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
<Button
|
||||
label="Approve Stock"
|
||||
icon={<CheckCircle size={18} />}
|
||||
onClick={() => { updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Approved'); setSelectedRequest(null); }}
|
||||
className="flex-1 py-3 rounded-xl font-extrabold cursor-pointer transition-colors text-white flex items-center justify-center gap-2 shadow-sm"
|
||||
style={{ background: '#10b981', border: '1px solid #059669' }}
|
||||
>
|
||||
<CheckCircle size={18} /> Approve Stock
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<Button
|
||||
label="Reject"
|
||||
variant="destructive"
|
||||
icon={<X size={18} />}
|
||||
onClick={() => { updateProductRequestStatus(req.locationid, req.requestid, String(req.productid), 'Rejected'); setSelectedRequest(null); }}
|
||||
className="flex-1 py-3 rounded-xl font-extrabold cursor-pointer transition-colors text-rose-600 flex items-center justify-center gap-2 shadow-sm"
|
||||
style={{ background: tint('#f43f5e'), border: `1px solid ${edge('#f43f5e')}` }}
|
||||
>
|
||||
<X size={18} /> Reject
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2 p-3 bg-slate-50 rounded-xl border border-slate-200 shadow-sm text-center">
|
||||
<span className="text-sm font-bold text-slate-500">
|
||||
<div className="flex items-center justify-center gap-2 p-3 rounded-xl text-center" style={{ background: 'rgba(127,127,127,0.06)' }}>
|
||||
<span className="text-sm font-bold opacity-70">
|
||||
This request has been {req.pickData.status.toLowerCase()}.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -13,10 +13,9 @@
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
ShoppingBag, Clock, CheckCircle2, XCircle, MapPin, Phone,
|
||||
Package, Loader2, X, Download, ChevronLeft, ChevronRight, Truck,
|
||||
Package, Loader2, X, Download, ChevronLeft, ChevronRight,
|
||||
UserCheck,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
@@ -28,10 +27,21 @@ import {
|
||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||
import { shortTime } from '../services/fiestaMappers';
|
||||
import {
|
||||
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, SearchPill, FilterBar, TH_STYLE,
|
||||
ORDER_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT,
|
||||
tint, soft, edge, ring,
|
||||
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, SearchPill, FilterBar, Card as ShellCard,
|
||||
ORDER_STATUS, statusColor, BRAND, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT,
|
||||
tint, soft, edge,
|
||||
} from './consoleUi';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
import { DateInput } from '@astryxdesign/core/DateInput';
|
||||
import type { ISODateString } from '@astryxdesign/core/utils';
|
||||
import { Heading } from '@astryxdesign/core/Heading';
|
||||
import { Spinner } from '@astryxdesign/core/Spinner';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import {
|
||||
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
|
||||
} from '@astryxdesign/core/Table';
|
||||
|
||||
interface OrdersDeliveriesViewProps {
|
||||
searchQuery?: string;
|
||||
@@ -187,10 +197,28 @@ export default function OrdersDeliveriesView({
|
||||
}
|
||||
right={
|
||||
<div className="flex items-center gap-3">
|
||||
{/* PHASE 6 — two unlabelled `<input type="date">`. A screen reader
|
||||
announced only "date" for both, so which end of the range you
|
||||
were on was invisible. The min/max cross-guard is preserved. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input type="date" value={fromdate} max={todate} onChange={(e) => setFromdate(e.target.value)} className="rounded-full outline-none font-semibold text-xs transition-colors" style={{ padding: '6px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }} />
|
||||
<DateInput
|
||||
label="From date"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={fromdate as ISODateString}
|
||||
max={todate as ISODateString}
|
||||
onChange={(v) => { if (v) setFromdate(v); }}
|
||||
/>
|
||||
<span className="text-zinc-400 font-bold px-1 text-xs">—</span>
|
||||
<input type="date" value={todate} min={fromdate} max={todayStr} onChange={(e) => setTodate(e.target.value)} className="rounded-full outline-none font-semibold text-xs transition-colors" style={{ padding: '6px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }} />
|
||||
<DateInput
|
||||
label="To date"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={todate as ISODateString}
|
||||
min={fromdate as ISODateString}
|
||||
max={todayStr as ISODateString}
|
||||
onChange={(v) => { if (v) setTodate(v); }}
|
||||
/>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full font-extrabold"
|
||||
style={{ padding: '6px 12px', fontSize: 12, background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}`, color: BRAND }}>
|
||||
@@ -220,90 +248,92 @@ export default function OrdersDeliveriesView({
|
||||
<div className="w-full lg:w-64">
|
||||
<SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders (Ctrl+K)…" inputRef={searchRef} />
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
label="CSV"
|
||||
size="sm"
|
||||
icon={<Download size={13} />}
|
||||
isDisabled={rows.length === 0}
|
||||
onClick={exportCsv}
|
||||
disabled={rows.length === 0}
|
||||
title="Export current view to CSV"
|
||||
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap shrink-0"
|
||||
style={{ padding: '8px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
|
||||
>
|
||||
<Download size={13} /> CSV
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
||||
<ShellCard className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" style={{ minWidth: 900 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<Table style={{ minWidth: 900 }}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{['#', 'Order', 'Branch', 'Pickup', 'Drop', 'Qty', 'COD', 'Amount', 'Status', ''].map((h, i) => (
|
||||
<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>
|
||||
<TableHeaderCell key={i}>{h}</TableHeaderCell>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{allOrdersQ.isLoading ? (
|
||||
<tr><td colSpan={10} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}>
|
||||
<span className="inline-flex items-center gap-2 text-xs font-semibold">
|
||||
<Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading orders…
|
||||
</span>
|
||||
</td></tr>
|
||||
<TableRow><TableCell colSpan={10}>
|
||||
<div className="py-8 flex justify-center"><Spinner label="Loading orders…" /></div>
|
||||
</TableCell></TableRow>
|
||||
) : pageRows.length === 0 ? (
|
||||
<tr><td colSpan={10} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>
|
||||
No {status} orders found for this date range or search.
|
||||
</td></tr>
|
||||
<TableRow><TableCell colSpan={10}>
|
||||
<EmptyState
|
||||
isCompact
|
||||
icon={<Package size={26} />}
|
||||
title={`No ${status} orders found`}
|
||||
description="Nothing matches this date range or search."
|
||||
/>
|
||||
</TableCell></TableRow>
|
||||
) : (
|
||||
pageRows.map((r, i) => {
|
||||
const st = fstr(r.orderstatus).toLowerCase();
|
||||
const cod = fnum(r.collectionamt);
|
||||
const amount = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
|
||||
return (
|
||||
<tr
|
||||
key={fstr(r.orderid) || fstr(r.orderheaderid) || i}
|
||||
className="transition-colors align-top"
|
||||
style={{ borderBottom: `1px solid ${DIVIDER}` }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
|
||||
// Keyed on orderheaderid first. `orderid` is not unique in
|
||||
// this tenant's data — the same defect fixed in OrdersView,
|
||||
// which reads the same rows through the same endpoint.
|
||||
<TableRow key={fstr(r.orderheaderid) || fstr(r.orderid) || i}>
|
||||
<TableCell><span className="font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</span></TableCell>
|
||||
<TableCell>
|
||||
<p className="font-extrabold font-mono text-[13px]">{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
|
||||
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}>
|
||||
<MapPin size={11} /> {fstr(r.applocation) || '—'}
|
||||
</span>
|
||||
{fstr(r.locationname) && <p className="text-[10px] truncate max-w-[120px]" style={{ color: TEXT_2 }}>{fstr(r.locationname)}</p>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="font-bold text-[12px] truncate max-w-[140px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="font-bold text-[12px] truncate max-w-[140px]">{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
|
||||
<p className="text-[10px] truncate max-w-[140px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="font-bold text-[12px] truncate max-w-[140px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="font-bold text-[12px] truncate max-w-[140px]">{fstr(r.deliverycustomer) || '—'}</p>
|
||||
<p className="text-[10px] truncate max-w-[140px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: cod > 0 ? TEXT : TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: amount > 0 ? TEXT : TEXT_3 }}>{amount > 0 ? `₹${amount.toLocaleString('en-IN')}` : '—'}</td>
|
||||
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
|
||||
<td className="px-3 py-2.5 text-right">
|
||||
<button onClick={() => setDetailOrder(r)}
|
||||
className="rounded-full font-extrabold cursor-pointer transition-colors"
|
||||
style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>
|
||||
View
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
<TableCell><span className="font-mono text-[12px]">{fnum(r.quantity) || '—'}</span></TableCell>
|
||||
<TableCell><span className="font-mono text-[12px] font-semibold" style={cod > 0 ? undefined : { color: TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'}</span></TableCell>
|
||||
<TableCell><span className="font-mono text-[12px] font-semibold" style={amount > 0 ? undefined : { color: TEXT_3 }}>{amount > 0 ? `₹${amount.toLocaleString('en-IN')}` : '—'}</span></TableCell>
|
||||
<TableCell><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">
|
||||
<Button
|
||||
label="View"
|
||||
aria-label={`View order ${fstr(r.orderid) || fstr(r.orderheaderid)}`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setDetailOrder(r)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Totals footer */}
|
||||
@@ -322,16 +352,29 @@ export default function OrdersDeliveriesView({
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: TEXT_2 }}>
|
||||
Page {pageno} · {pageRows.length} of {rows.length} shown
|
||||
</span>
|
||||
{/* Same reasoning as OrdersView: this list is cursor-shaped, so
|
||||
Astryx's Pagination (which wants a known page count) would have to
|
||||
invent a total it does not have. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<PagerBtn disabled={pageno === 1} onClick={() => setPageno((p) => Math.max(1, p - 1))}>
|
||||
<ChevronLeft size={13} /> Prev
|
||||
</PagerBtn>
|
||||
<PagerBtn disabled={!hasNext} onClick={() => setPageno((p) => p + 1)}>
|
||||
Next <ChevronRight size={13} />
|
||||
</PagerBtn>
|
||||
<Button
|
||||
label="Prev"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<ChevronLeft size={13} />}
|
||||
isDisabled={pageno === 1}
|
||||
onClick={() => setPageno((p) => Math.max(1, p - 1))}
|
||||
/>
|
||||
<Button
|
||||
label="Next"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
endContent={<ChevronRight size={13} />}
|
||||
isDisabled={!hasNext}
|
||||
onClick={() => setPageno((p) => p + 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ShellCard>
|
||||
|
||||
{detailOrder && <OrderDetailModal order={detailOrder} onClose={() => setDetailOrder(null)} />}
|
||||
</div>
|
||||
@@ -350,16 +393,6 @@ function TotalChip({ label, value, color }: { label: string; value: string; colo
|
||||
);
|
||||
}
|
||||
|
||||
function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Order details modal ────────────────────────────────────────────────────────
|
||||
function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void }) {
|
||||
const orderheaderid = order.orderheaderid ?? order.orderid;
|
||||
@@ -387,26 +420,23 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
|
||||
{ label: 'Delivered', field: 'deliverytime' },
|
||||
];
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center p-4"
|
||||
style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
className="bg-white max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200"
|
||||
style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}
|
||||
>
|
||||
{/* Brand accent bar */}
|
||||
<div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${BRAND_LIGHT} 100%)` }} />
|
||||
|
||||
// PHASE 6 — the portal and the `width: min(32rem, 92vw)` are gone with the
|
||||
// overlay. Both existed because a `fixed inset-0` resolves against the nearest
|
||||
// transformed ancestor rather than the viewport, and this page has several, so
|
||||
// the panel used to collapse. Dialog renders in the browser's top layer, which
|
||||
// no ancestor can contain.
|
||||
return (
|
||||
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
|
||||
<div className="flex flex-col max-h-[90vh]">
|
||||
{/* Modal header */}
|
||||
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}>
|
||||
<Package size={16} style={{ color: BRAND }} />
|
||||
Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}
|
||||
</h4>
|
||||
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button>
|
||||
<div className="p-4 flex justify-between items-center shrink-0">
|
||||
<Heading level={3}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Package size={16} style={{ color: BRAND }} />
|
||||
Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}
|
||||
</span>
|
||||
</Heading>
|
||||
<IconButton label="Close order details" variant="ghost" size="sm" icon={<X size={16} />} onClick={onClose} />
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
@@ -498,14 +528,10 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white"
|
||||
style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}>
|
||||
Close
|
||||
</button>
|
||||
<div className="p-3 flex justify-end shrink-0">
|
||||
<Button label="Close" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2, Download, UserCheck, ClipboardList, ArrowLeft } from 'lucide-react';
|
||||
import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Download, UserCheck, ClipboardList, ArrowLeft } from 'lucide-react';
|
||||
import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails, useFiestaRiders, useFiestaAssignRider, useFiestaNotifyRider } from '../services/fiestaQueries';
|
||||
import {
|
||||
FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, RIDER_MESSAGES, RiderNotReachableError,
|
||||
@@ -21,9 +21,26 @@ import {
|
||||
} from '../services/fiestaApi';
|
||||
import { shortTime } from '../services/fiestaMappers';
|
||||
import {
|
||||
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
|
||||
ORDER_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge, ring,
|
||||
KpiStrip, Pill, StatusChip, SearchPill, FilterBar, Card as ShellCard,
|
||||
ORDER_STATUS, statusColor, BRAND, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge, ring,
|
||||
} from './consoleUi';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
import { DateInput } from '@astryxdesign/core/DateInput';
|
||||
// DateInput's value is a branded `YYYY-MM-DD` template-literal type, while our
|
||||
// dates are plain strings out of `ymd()`. Importing the real type keeps the cast
|
||||
// honest and localised instead of reaching for `as any` at four call sites.
|
||||
import type { ISODateString } from '@astryxdesign/core/utils';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
||||
import { Heading } from '@astryxdesign/core/Heading';
|
||||
import { Spinner } from '@astryxdesign/core/Spinner';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import {
|
||||
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
|
||||
} from '@astryxdesign/core/Table';
|
||||
|
||||
interface OrdersViewProps {
|
||||
searchQuery?: string;
|
||||
@@ -357,12 +374,28 @@ export default function OrdersView({
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
{/* PHASE 6 — two bare `<input type="date">` become DateInputs. Both
|
||||
were unlabelled, so a screen reader announced only "date"; and the
|
||||
native picker was the only way in, with no keyboard entry format
|
||||
hint. The min/max cross-guard between the two is preserved. */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<input type="date" value={fromdate} max={todate} onChange={(e) => setScope({ from: e.target.value })}
|
||||
className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} />
|
||||
<DateInput
|
||||
label="From date"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={fromdate as ISODateString}
|
||||
max={todate as ISODateString}
|
||||
onChange={(v) => { if (v) setScope({ from: v }); }}
|
||||
/>
|
||||
<span style={{ color: TEXT_3 }}>→</span>
|
||||
<input type="date" value={todate} min={fromdate} onChange={(e) => setScope({ to: e.target.value })}
|
||||
className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }} />
|
||||
<DateInput
|
||||
label="To date"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={todate as ISODateString}
|
||||
min={fromdate as ISODateString}
|
||||
onChange={(v) => { if (v) setScope({ to: v }); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FilterBar>
|
||||
@@ -386,27 +419,28 @@ export default function OrdersView({
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full lg:w-auto lg:shrink-0">
|
||||
{branches.length > 1 && (
|
||||
<select
|
||||
value={branch}
|
||||
onChange={(e) => { setBranch(Number(e.target.value)); setPageno(1); }}
|
||||
title="Filter by branch / app-location"
|
||||
className="rounded-full font-bold text-xs outline-none cursor-pointer shrink-0"
|
||||
style={{ padding: '7px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }}
|
||||
>
|
||||
<option value={0}>All branches</option>
|
||||
{branches.map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}
|
||||
</select>
|
||||
<div className="shrink-0 min-w-[150px]">
|
||||
<Selector
|
||||
label="Filter by branch"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={String(branch)}
|
||||
onChange={(v) => { setBranch(Number(v) || 0); setPageno(1); }}
|
||||
options={[
|
||||
{ value: '0', label: 'All branches' },
|
||||
...branches.map((b) => ({ value: String(b.id), label: b.name })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full lg:w-60"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders (Ctrl+K)…" inputRef={searchRef} /></div>
|
||||
<button
|
||||
<Button
|
||||
label="CSV"
|
||||
size="sm"
|
||||
icon={<Download size={13} />}
|
||||
isDisabled={rows.length === 0}
|
||||
onClick={exportCsv}
|
||||
disabled={rows.length === 0}
|
||||
title="Export current view to CSV"
|
||||
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap shrink-0"
|
||||
style={{ padding: '8px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
|
||||
>
|
||||
<Download size={13} /> CSV
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FilterBar>
|
||||
@@ -417,131 +451,170 @@ export default function OrdersView({
|
||||
<span className="inline-flex items-center gap-1.5 font-extrabold text-xs" style={{ color: BRAND }}>
|
||||
<UserCheck size={15} /> {selected.size} selected
|
||||
</span>
|
||||
<div className="flex bg-white rounded-full p-0.5 ml-2 border" style={{ borderColor: edge(BRAND) }}>
|
||||
<button
|
||||
onClick={() => setRiderSource('all')}
|
||||
title="Every rider on duty at this branch right now"
|
||||
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors ${riderSource === 'all' ? '' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
style={riderSource === 'all' ? { background: tint(BRAND), color: BRAND } : undefined}
|
||||
{/* PHASE 6 — the two-button rider-source toggle is a SegmentedControl.
|
||||
It was a pair of buttons that looked like a segmented control but
|
||||
announced as two unrelated buttons with no indication which was
|
||||
active; the real one exposes the selection to assistive tech. */}
|
||||
<div className="ml-2">
|
||||
<SegmentedControl
|
||||
label="Rider source"
|
||||
size="sm"
|
||||
value={riderSource}
|
||||
onChange={(v) => setRiderSource(v as 'all' | 'partner')}
|
||||
>
|
||||
On Duty
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRiderSource('partner')}
|
||||
disabled={!orderPartnerId}
|
||||
title={orderPartnerId ? 'Only riders from the partner carrying these orders' : 'These orders name no partner'}
|
||||
className={`px-3 py-1 text-[11px] font-bold rounded-full transition-colors disabled:opacity-40 ${riderSource === 'partner' ? '' : 'text-slate-500 hover:bg-slate-50'}`}
|
||||
style={riderSource === 'partner' ? { background: tint(BRAND), color: BRAND } : undefined}
|
||||
>
|
||||
This Partner
|
||||
</button>
|
||||
<SegmentedControlItem value="all" label="On Duty" />
|
||||
<SegmentedControlItem value="partner" label="This Partner" isDisabled={!orderPartnerId} />
|
||||
</SegmentedControl>
|
||||
</div>
|
||||
<select
|
||||
value={assignRiderId}
|
||||
onChange={(e) => setAssignRiderId(Number(e.target.value))}
|
||||
disabled={selected.size === 0}
|
||||
title="Choose a rider to assign"
|
||||
className="rounded-full font-bold text-xs outline-none cursor-pointer disabled:opacity-50"
|
||||
style={{ padding: '7px 12px', border: `1.5px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 260 }}
|
||||
>
|
||||
<option value={0}>
|
||||
{ridersQ.isLoading
|
||||
? 'Loading riders…'
|
||||
: riderOptions.length
|
||||
? 'Select rider…'
|
||||
: riderSource === 'partner'
|
||||
? 'No riders on duty for this partner'
|
||||
: 'No riders on duty right now'}
|
||||
</option>
|
||||
{riderOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
<button
|
||||
{/* The empty-list wording carried real information — which of the two
|
||||
scopes came back empty — so it moves to `placeholder` rather than
|
||||
being flattened into a generic "Select…". */}
|
||||
<div className="min-w-[200px] max-w-[260px]">
|
||||
<Selector
|
||||
label="Choose a rider to assign"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={assignRiderId ? String(assignRiderId) : ''}
|
||||
onChange={(v) => setAssignRiderId(Number(v) || 0)}
|
||||
isDisabled={selected.size === 0}
|
||||
isLoading={ridersQ.isLoading}
|
||||
placeholder={
|
||||
ridersQ.isLoading
|
||||
? 'Loading riders…'
|
||||
: riderOptions.length
|
||||
? 'Select rider…'
|
||||
: riderSource === 'partner'
|
||||
? 'No riders on duty for this partner'
|
||||
: 'No riders on duty right now'
|
||||
}
|
||||
options={riderOptions.map((o) => ({ value: String(o.id), label: o.label }))}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
label="Assign rider"
|
||||
size="sm"
|
||||
icon={<UserCheck size={13} />}
|
||||
isLoading={assignMut.isPending}
|
||||
isDisabled={!assignRiderId || selected.size === 0 || assignMut.isPending}
|
||||
onClick={handleAssign}
|
||||
disabled={!assignRiderId || selected.size === 0 || assignMut.isPending}
|
||||
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{ padding: '7px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
|
||||
>
|
||||
{assignMut.isPending ? <Loader2 size={13} className="animate-spin" /> : <UserCheck size={13} />} Assign rider
|
||||
</button>
|
||||
/>
|
||||
{selected.size > 0 && (
|
||||
<button onClick={() => setSelected(new Set())} className="rounded-full font-bold text-xs cursor-pointer" style={{ padding: '7px 12px', border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
|
||||
Clear
|
||||
</button>
|
||||
<Button label="Clear" variant="secondary" size="sm" onClick={() => setSelected(new Set())} />
|
||||
)}
|
||||
{assignMsg && <span className="text-[11px] font-semibold ml-auto" style={{ color: TEXT_2 }}>{assignMsg}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white border overflow-hidden" style={{ borderColor: BORDER }}>
|
||||
{/* Table
|
||||
PHASE 6 — raw <table> onto the Astryx primitives, and the three bare
|
||||
`<input type="checkbox">` onto CheckboxInput. The row boxes carried an
|
||||
aria-label but no visible label and no focus ring beyond the browser
|
||||
default; the disabled one explained itself only through `title`, which
|
||||
a keyboard user never sees. */}
|
||||
<ShellCard className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" style={{ minWidth: 960 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-left" style={TH_STYLE}>
|
||||
<input type="checkbox" checked={allSelected} onChange={toggleAll} disabled={assignableRows.length === 0} aria-label="Select all assignable orders" style={{ accentColor: BRAND, cursor: 'pointer', width: 15, height: 15 }} />
|
||||
</th>
|
||||
<Table style={{ minWidth: 960 }}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
<CheckboxInput
|
||||
label="Select all assignable orders"
|
||||
isLabelHidden
|
||||
value={allSelected}
|
||||
isDisabled={assignableRows.length === 0}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</TableHeaderCell>
|
||||
{['#', 'Order', 'Branch', 'Pickup', 'Drop', 'Qty', 'COD', 'KMs', 'Charges', 'Status', ''].map((h, i) => (
|
||||
<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>
|
||||
<TableHeaderCell key={i}>{h}</TableHeaderCell>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ordersQ.isLoading ? (
|
||||
<tr><td colSpan={12} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}>
|
||||
<span className="inline-flex items-center gap-2 text-xs font-semibold"><Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading orders…</span>
|
||||
</td></tr>
|
||||
<TableRow><TableCell colSpan={12}>
|
||||
<div className="py-8 flex justify-center"><Spinner label="Loading orders…" /></div>
|
||||
</TableCell></TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<tr><td colSpan={12} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No orders found for this status, date range, or search.</td></tr>
|
||||
<TableRow><TableCell colSpan={12}>
|
||||
<EmptyState
|
||||
isCompact
|
||||
icon={<Package size={26} />}
|
||||
title="No orders found"
|
||||
description="Nothing matches this status, date range, or search."
|
||||
/>
|
||||
</TableCell></TableRow>
|
||||
) : (
|
||||
rows.map((r, i) => {
|
||||
const st = fstr(r.orderstatus).toLowerCase();
|
||||
const cod = fnum(r.collectionamt);
|
||||
const charges = fnum(r.deliverycharge) || fnum(r.deliverycharges);
|
||||
const isSel = selected.has(rowKey(r));
|
||||
return (
|
||||
<tr key={fstr(r.orderid) || i} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER_C}`, background: selected.has(rowKey(r)) ? tint(BRAND) : 'transparent' }}
|
||||
onMouseEnter={(e) => { if (!selected.has(rowKey(r))) e.currentTarget.style.background = SURFACE_ALT; }} onMouseLeave={(e) => { e.currentTarget.style.background = selected.has(rowKey(r)) ? tint(BRAND) : 'transparent'; }}>
|
||||
<td className="px-3 py-2.5">
|
||||
// `rowKey`, not `orderid`. `orderid` is NOT unique — this
|
||||
// tenant returns several rows sharing one (three "1135-1" on
|
||||
// the store console alone), so React logged a duplicate-key
|
||||
// error for most of the page and was free to duplicate or
|
||||
// drop rows. `rowKey` prefers `orderheaderid`, the actual
|
||||
// primary key, and is already what the selection Set uses —
|
||||
// so row identity and checkbox identity now agree instead of
|
||||
// drifting apart on re-render.
|
||||
//
|
||||
// The selected tint stays inline: it is a brand wash keyed to
|
||||
// selection state, which Table has no prop for.
|
||||
<TableRow key={rowKey(r) || i} style={isSel ? { background: tint(BRAND) } : undefined}>
|
||||
<TableCell>
|
||||
{st === 'created' ? (
|
||||
<input type="checkbox" checked={selected.has(rowKey(r))} onChange={() => toggleRow(rowKey(r))} aria-label="Select order" style={{ accentColor: BRAND, cursor: 'pointer', width: 15, height: 15 }} />
|
||||
<CheckboxInput
|
||||
label="Select order"
|
||||
isLabelHidden
|
||||
value={isSel}
|
||||
onChange={() => toggleRow(rowKey(r))}
|
||||
/>
|
||||
) : (
|
||||
<input type="checkbox" disabled aria-label="Order cannot be assigned" style={{ width: 15, height: 15, opacity: 0.3 }} title="Only 'created' orders can be assigned" />
|
||||
<CheckboxInput
|
||||
label="Only 'created' orders can be assigned"
|
||||
isLabelHidden
|
||||
value={false}
|
||||
isDisabled
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
|
||||
</TableCell>
|
||||
<TableCell><span className="font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</span></TableCell>
|
||||
<TableCell>
|
||||
<p className="font-extrabold font-mono text-[13px]">{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
|
||||
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}>
|
||||
<MapPin size={11} /> {fstr(r.applocation) || '—'}
|
||||
</span>
|
||||
{fstr(r.locationname) && <p className="text-[10px] truncate max-w-[130px]" style={{ color: TEXT_2 }}>{fstr(r.locationname)}</p>}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="font-bold text-[12px] truncate max-w-[150px]">{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
|
||||
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="font-bold text-[12px] truncate max-w-[150px]">{fstr(r.deliverycustomer) || '—'}</p>
|
||||
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: cod > 0 ? TEXT : TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: fnum(r.kms) ? TEXT_2 : TEXT_3 }}>{fnum(r.kms) ? fnum(r.kms).toFixed(1) : '—'}</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[12px] font-semibold" style={{ color: charges > 0 ? TEXT : TEXT_3 }}>{charges > 0 ? `₹${charges.toLocaleString('en-IN')}` : '—'}</td>
|
||||
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
|
||||
<td className="px-3 py-2.5 text-right">
|
||||
<button onClick={() => setDetailOrder(r)} className="rounded-full font-extrabold cursor-pointer transition-colors"
|
||||
style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>View</button>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
<TableCell><span className="font-mono text-[12px]">{fnum(r.quantity) || '—'}</span></TableCell>
|
||||
<TableCell><span className="font-mono text-[12px] font-semibold" style={cod > 0 ? undefined : { color: TEXT_3 }}>{cod > 0 ? `₹${cod.toLocaleString('en-IN')}` : '—'}</span></TableCell>
|
||||
<TableCell><span className="font-mono text-[12px]" style={{ color: fnum(r.kms) ? TEXT_2 : TEXT_3 }}>{fnum(r.kms) ? fnum(r.kms).toFixed(1) : '—'}</span></TableCell>
|
||||
<TableCell><span className="font-mono text-[12px] font-semibold" style={charges > 0 ? undefined : { color: TEXT_3 }}>{charges > 0 ? `₹${charges.toLocaleString('en-IN')}` : '—'}</span></TableCell>
|
||||
<TableCell><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">
|
||||
<Button label="View" variant="secondary" size="sm" onClick={() => setDetailOrder(r)} />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/* Totals across the filtered rows */}
|
||||
{rows.length > 0 && (
|
||||
@@ -554,12 +627,30 @@ export default function OrdersView({
|
||||
)}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: TEXT_2 }}>Page {pageno} · {rows.length} shown</span>
|
||||
{/* Astryx `Pagination` wants a known page count; this list is
|
||||
cursor-shaped (hasNext is inferred from a full page), so Prev/Next
|
||||
stay two Buttons rather than a pager that would have to invent a
|
||||
total it does not have. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<PagerBtn disabled={pageno === 1} onClick={() => setPageno((p) => Math.max(1, p - 1))}><ChevronLeft size={13} /> Prev</PagerBtn>
|
||||
<PagerBtn disabled={!hasNext} onClick={() => setPageno((p) => p + 1)}>Next <ChevronRight size={13} /></PagerBtn>
|
||||
<Button
|
||||
label="Prev"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<ChevronLeft size={13} />}
|
||||
isDisabled={pageno === 1}
|
||||
onClick={() => setPageno((p) => Math.max(1, p - 1))}
|
||||
/>
|
||||
<Button
|
||||
label="Next"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
endContent={<ChevronRight size={13} />}
|
||||
isDisabled={!hasNext}
|
||||
onClick={() => setPageno((p) => p + 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ShellCard>
|
||||
|
||||
{detailOrder && <OrderDetailModal order={detailOrder} onClose={() => setDetailOrder(null)} />}
|
||||
|
||||
@@ -568,38 +659,37 @@ export default function OrdersView({
|
||||
Opens the full-page review/assign view on click. */}
|
||||
{status === 'created' && selected.size > 1 && !showSelected &&
|
||||
createPortal(
|
||||
<button
|
||||
onClick={() => setShowSelected(true)}
|
||||
title={`Review & assign ${selected.size} selected order${selected.size === 1 ? '' : 's'}`}
|
||||
className="group fixed right-0 z-[150] flex items-center gap-2 py-3 pl-4 pr-5 text-white font-extrabold text-xs cursor-pointer transition-all duration-200 hover:pr-7 animate-in slide-in-from-right-4"
|
||||
style={{ top: '70%', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, borderTopLeftRadius: 9999, borderBottomLeftRadius: 9999, boxShadow: `0 10px 30px ${ring(BRAND)}` }}
|
||||
>
|
||||
<span className="relative inline-flex">
|
||||
<ClipboardList size={18} />
|
||||
<span className="absolute -top-2.5 -right-2.5 min-w-[17px] h-[17px] px-1 rounded-full bg-rose-500 text-[9px] font-black flex items-center justify-center ring-2 ring-white">{selected.size}</span>
|
||||
</span>
|
||||
<span className="max-w-0 overflow-hidden group-hover:max-w-[80px] transition-all duration-200 whitespace-nowrap">Review</span>
|
||||
</button>,
|
||||
// PHASE 6 — the label used to be collapsed to `max-w-0` and revealed
|
||||
// only on hover, so the button read as an unlabelled icon until you
|
||||
// pointed at it and was invisible to anyone not using a mouse. The
|
||||
// Astryx Button carries the count in its label instead.
|
||||
<div className="fixed right-4 z-[150] animate-in slide-in-from-right-4" style={{ top: '70%' }}>
|
||||
<Button
|
||||
label={`Review ${selected.size} selected`}
|
||||
icon={<ClipboardList size={16} />}
|
||||
onClick={() => setShowSelected(true)}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{showSelected &&
|
||||
createPortal(
|
||||
<SelectedOrdersPage
|
||||
rows={selectedRows}
|
||||
rowKey={rowKey}
|
||||
riderOptions={riderOptions}
|
||||
ridersLoading={ridersQ.isLoading}
|
||||
assignRiderId={assignRiderId}
|
||||
setAssignRiderId={setAssignRiderId}
|
||||
assigning={assignMut.isPending}
|
||||
assignMsg={assignMsg}
|
||||
onAssign={handleAssign}
|
||||
onRemove={(k) => toggleRow(k)}
|
||||
onClose={() => setShowSelected(false)}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
{/* No createPortal any more — Dialog renders in the browser's top layer,
|
||||
which is already outside every ancestor's containing block. */}
|
||||
{showSelected && (
|
||||
<SelectedOrdersPage
|
||||
rows={selectedRows}
|
||||
rowKey={rowKey}
|
||||
riderOptions={riderOptions}
|
||||
ridersLoading={ridersQ.isLoading}
|
||||
assignRiderId={assignRiderId}
|
||||
setAssignRiderId={setAssignRiderId}
|
||||
assigning={assignMut.isPending}
|
||||
assignMsg={assignMsg}
|
||||
onAssign={handleAssign}
|
||||
onRemove={(k) => toggleRow(k)}
|
||||
onClose={() => setShowSelected(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -623,75 +713,114 @@ function SelectedOrdersPage({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] overflow-y-auto animate-in fade-in duration-200" style={{ background: '#f8fafc' }}>
|
||||
{/* Sticky page header with the assign controls */}
|
||||
<div className="sticky top-0 z-10 border-b" style={{ background: '#fff', borderColor: BORDER }}>
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-8 py-4 flex flex-wrap items-center gap-3">
|
||||
<button onClick={onClose} className="inline-flex items-center gap-1.5 rounded-full font-bold text-xs cursor-pointer" style={{ padding: '8px 14px', border: `1px solid ${BORDER}`, color: TEXT_2, background: '#fff' }}>
|
||||
<ArrowLeft size={14} /> Back to orders
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-9 w-9 rounded-xl flex items-center justify-center" style={{ background: tint(BRAND), color: BRAND }}><ClipboardList size={18} /></span>
|
||||
<div>
|
||||
<h1 className="font-bold text-lg tracking-tight leading-none" style={{ color: TEXT }}>Selected Orders</h1>
|
||||
<p className="text-[11px] mt-1" style={{ color: TEXT_2 }}>{rows.length} order{rows.length === 1 ? '' : 's'} ready to assign</p>
|
||||
// PHASE 6 — was a `fixed inset-0` page takeover with no Escape and no focus
|
||||
// trap: tabbing from it walked straight back into the orders board
|
||||
// underneath. Dialog's fullscreen variant gives both, and keeps the same
|
||||
// full-bleed shape.
|
||||
//
|
||||
// What it does NOT give is focus restore — verified in Chrome: closing this
|
||||
// leaves focus on <body>, not on the control that opened it. consoleUi's
|
||||
// SlideDrawer restores it because it does so by hand. That gap is Astryx's,
|
||||
// it applies to every Dialog in the app, and it is a missing gain rather
|
||||
// than a regression (the old overlay managed no focus at all), so it is not
|
||||
// patched here one screen at a time.
|
||||
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} variant="fullscreen" purpose="info">
|
||||
<div className="h-full overflow-y-auto">
|
||||
{/* Sticky page header with the assign controls */}
|
||||
<div className="sticky top-0 z-10 border-b" style={{ background: 'var(--color-background-card, #fff)', borderColor: BORDER }}>
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-8 py-4 flex flex-wrap items-center gap-3">
|
||||
<Button label="Back to orders" variant="secondary" size="sm" icon={<ArrowLeft size={14} />} onClick={onClose} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-9 w-9 rounded-xl flex items-center justify-center" style={{ background: tint(BRAND), color: BRAND }}><ClipboardList size={18} /></span>
|
||||
<div>
|
||||
<Heading level={2}>Selected Orders</Heading>
|
||||
<p className="text-[11px] mt-1" style={{ color: TEXT_2 }}>{rows.length} order{rows.length === 1 ? '' : 's'} ready to assign</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<div className="min-w-[200px] max-w-[260px]">
|
||||
<Selector
|
||||
label="Choose a rider to assign"
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={assignRiderId ? String(assignRiderId) : ''}
|
||||
onChange={(v) => setAssignRiderId(Number(v) || 0)}
|
||||
isLoading={ridersLoading}
|
||||
placeholder={ridersLoading ? 'Loading riders…' : riderOptions.length ? 'Select rider…' : 'No riders available'}
|
||||
options={riderOptions.map((o) => ({ value: String(o.id), label: o.label }))}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
label="Assign rider"
|
||||
size="sm"
|
||||
icon={<UserCheck size={14} />}
|
||||
isLoading={assigning}
|
||||
isDisabled={!assignRiderId || rows.length === 0 || assigning}
|
||||
onClick={onAssign}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<select value={assignRiderId} onChange={(e) => setAssignRiderId(Number(e.target.value))} className="rounded-full font-bold text-xs outline-none cursor-pointer" style={{ padding: '8px 12px', border: `1.5px solid ${edge(BRAND)}`, background: '#fff', color: BRAND, maxWidth: 260 }}>
|
||||
<option value={0}>{ridersLoading ? 'Loading riders…' : riderOptions.length ? 'Select rider…' : 'No riders available'}</option>
|
||||
{riderOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
<button onClick={onAssign} disabled={!assignRiderId || rows.length === 0 || assigning} className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed" style={{ padding: '8px 16px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}>
|
||||
{assigning ? <Loader2 size={14} className="animate-spin" /> : <UserCheck size={14} />} Assign rider
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6">
|
||||
{assignMsg && <div className="mb-4 rounded-xl px-4 py-2.5 text-xs font-semibold" style={{ background: tint(BRAND), border: `1px solid ${edge(BRAND)}`, color: BRAND }}>{assignMsg}</div>}
|
||||
{rows.length === 0 ? (
|
||||
<div className="bg-white border rounded-2xl p-12 text-center text-xs" style={{ borderColor: BORDER, color: TEXT_3 }}>
|
||||
No orders selected. <button onClick={onClose} className="font-bold underline cursor-pointer" style={{ color: BRAND }}>Go back</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border overflow-hidden" style={{ borderColor: BORDER }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" style={{ minWidth: 720 }}>
|
||||
<thead><tr>{['#', 'Order', 'Pickup', 'Drop', 'Status', ''].map((h, i) => <th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => {
|
||||
const st = fstr(r.orderstatus).toLowerCase();
|
||||
return (
|
||||
<tr key={rowKey(r) || i} style={{ borderBottom: `1px solid ${DIVIDER_C}` }}>
|
||||
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
|
||||
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="font-bold text-[12px] truncate max-w-[180px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
|
||||
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="font-bold text-[12px] truncate max-w-[180px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
|
||||
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
|
||||
<td className="px-3 py-2.5 text-right">
|
||||
<button onClick={() => onRemove(rowKey(r))} title="Remove from selection" className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={15} /></button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-5xl mx-auto px-4 md:px-8 py-6">
|
||||
{assignMsg && <div className="mb-4 rounded-xl px-4 py-2.5 text-xs font-semibold" style={{ background: tint(BRAND), border: `1px solid ${edge(BRAND)}`, color: BRAND }}>{assignMsg}</div>}
|
||||
{rows.length === 0 ? (
|
||||
<ShellCard className="p-12">
|
||||
<EmptyState
|
||||
title="No orders selected"
|
||||
actions={<Button label="Go back" variant="secondary" onClick={onClose} />}
|
||||
/>
|
||||
</ShellCard>
|
||||
) : (
|
||||
<ShellCard className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<Table style={{ minWidth: 720 }}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{['#', 'Order', 'Pickup', 'Drop', 'Status', ''].map((h, i) => <TableHeaderCell key={i}>{h}</TableHeaderCell>)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r, i) => {
|
||||
const st = fstr(r.orderstatus).toLowerCase();
|
||||
return (
|
||||
<TableRow key={rowKey(r) || i}>
|
||||
<TableCell><span className="font-mono" style={{ color: TEXT_3 }}>{i + 1}</span></TableCell>
|
||||
<TableCell>
|
||||
<p className="font-extrabold font-mono text-[13px]">{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
|
||||
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="font-bold text-[12px] truncate max-w-[180px]">{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
|
||||
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="font-bold text-[12px] truncate max-w-[180px]">{fstr(r.deliverycustomer) || '—'}</p>
|
||||
<p className="text-[10px] truncate max-w-[180px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
|
||||
</TableCell>
|
||||
<TableCell><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">
|
||||
<IconButton
|
||||
label={`Remove order ${fstr(r.orderid) || fstr(r.orderheaderid)} from selection`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<X size={15} />}
|
||||
onClick={() => onRemove(rowKey(r))}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</ShellCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -704,16 +833,6 @@ function TotalChip({ label, value, color }: { label: string; value: string; colo
|
||||
);
|
||||
}
|
||||
|
||||
function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled}
|
||||
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Order details modal ─────────────────────────────────────────────────────────
|
||||
function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void }) {
|
||||
const orderheaderid = order.orderheaderid ?? order.orderid;
|
||||
@@ -726,39 +845,42 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
|
||||
const st = fstr(order.orderstatus).toLowerCase();
|
||||
const total = fnum(order.deliveryamt) || fnum(order.orderamount);
|
||||
|
||||
// Portal to <body> so the overlay escapes any transformed / blurred / overflow
|
||||
// ancestor in the view tree — otherwise `fixed inset-0` resolves against that
|
||||
// ancestor (not the viewport) and the panel collapses to a sliver. The explicit
|
||||
// viewport-relative width is a belt-and-suspenders so sizing never depends on
|
||||
// percentage resolution against a broken containing block.
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div className="bg-white max-h-[90vh] flex flex-col overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: 'min(32rem, 92vw)', border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
|
||||
<div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${soft(BRAND)} 100%)` }} />
|
||||
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Package size={16} style={{ color: BRAND }} /> Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}</h4>
|
||||
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button>
|
||||
// PHASE 6 — the hand-rolled overlay is gone, and with it the portal and the
|
||||
// `width: min(32rem, 92vw)` workaround. Both existed for one reason: a
|
||||
// `fixed inset-0` resolves against the nearest transformed/filtered ancestor
|
||||
// rather than the viewport, and this page has several, so the panel used to
|
||||
// collapse to a sliver. Dialog renders in the browser's top layer, which no
|
||||
// ancestor can contain — so the escape hatches are no longer needed.
|
||||
return (
|
||||
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={512} maxHeight="90vh" purpose="info">
|
||||
<div className="flex flex-col max-h-[90vh]">
|
||||
<div className="p-4 flex justify-between items-center shrink-0">
|
||||
<Heading level={3}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Package size={16} style={{ color: BRAND }} /> Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}
|
||||
</span>
|
||||
</Heading>
|
||||
<IconButton label="Close" variant="ghost" size="sm" icon={<X size={16} />} onClick={onClose} />
|
||||
</div>
|
||||
<div className="p-4 space-y-4 overflow-y-auto flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} />
|
||||
<span className="text-[11px] font-medium" style={{ color: TEXT_2 }}>{shortTime(order.orderdate || order.deliverydate)}</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl space-y-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
||||
<div className="flex items-center gap-2 font-bold" style={{ color: TEXT }}>{fstr(order.deliverycustomer) || 'Customer'}</div>
|
||||
<div className="p-3 rounded-xl space-y-1.5" style={{ background: 'rgba(127,127,127,0.06)' }}>
|
||||
<div className="flex items-center gap-2 font-bold">{fstr(order.deliverycustomer) || 'Customer'}</div>
|
||||
{fstr(order.deliverycontactno) && <div className="flex items-center gap-2 font-mono text-xs" style={{ color: TEXT_2 }}><Phone size={12} /> {fstr(order.deliverycontactno)}</div>}
|
||||
<div className="flex items-start gap-2 text-xs" style={{ color: TEXT_2 }}><MapPin size={12} className="mt-0.5 shrink-0" /> <span className="leading-relaxed">{fstr(order.deliveryaddress) || fstr(order.deliverysuburb) || 'Address unavailable'}</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-extrabold uppercase tracking-wide block mb-2" style={{ color: TEXT_2 }}>Order Items</span>
|
||||
<div className="rounded-xl p-3" style={{ background: 'rgba(248,250,252,0.6)', border: `1px solid ${BORDER}` }}>
|
||||
{detailsQ.isLoading && <div className="py-2 flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_3 }}><Loader2 size={12} className="animate-spin" /> Loading line items…</div>}
|
||||
<div className="rounded-xl p-3" style={{ background: 'rgba(127,127,127,0.04)' }}>
|
||||
{detailsQ.isLoading && <div className="py-2 flex justify-center"><Spinner size="sm" label="Loading line items…" /></div>}
|
||||
{!detailsQ.isLoading && lines.length === 0 && <div className="py-2 text-[11px] font-medium" style={{ color: TEXT_3 }}>No line items returned for this order.</div>}
|
||||
{lines.map((item, idx) => (
|
||||
<div key={idx} className="py-2 flex justify-between items-center" style={{ borderTop: idx ? `1px solid ${DIVIDER_C}` : undefined }}>
|
||||
<div><p className="font-bold text-xs" style={{ color: TEXT }}>{item.name}</p><p className="text-[10px]" style={{ color: TEXT_2 }}>Qty: {item.quantity} × ₹{item.price}</p></div>
|
||||
<span className="font-extrabold font-mono text-xs" style={{ color: TEXT }}>₹{item.lineTotal.toLocaleString('en-IN')}</span>
|
||||
<div><p className="font-bold text-xs">{item.name}</p><p className="text-[10px]" style={{ color: TEXT_2 }}>Qty: {item.quantity} × ₹{item.price}</p></div>
|
||||
<span className="font-extrabold font-mono text-xs">₹{item.lineTotal.toLocaleString('en-IN')}</span>
|
||||
</div>
|
||||
))}
|
||||
{total > 0 && (
|
||||
@@ -769,13 +891,10 @@ function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT_LOCAL})` }}>Close</button>
|
||||
<div className="p-3 flex justify-end shrink-0">
|
||||
<Button label="Close" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const BRAND_LIGHT_LOCAL = '#9255AB';
|
||||
|
||||
@@ -26,6 +26,13 @@ import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { TimeInput } from '@astryxdesign/core/TimeInput';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { ToggleButton } from '@astryxdesign/core/ToggleButton';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Banner } from '@astryxdesign/core/Banner';
|
||||
import {
|
||||
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
|
||||
} from '@astryxdesign/core/Table';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
@@ -60,6 +67,7 @@ import {
|
||||
BORDER,
|
||||
BRAND,
|
||||
Card,
|
||||
Card as ShellCard,
|
||||
DIVIDER,
|
||||
Pill,
|
||||
StatusChip,
|
||||
@@ -67,7 +75,6 @@ import {
|
||||
TEXT,
|
||||
TEXT_2,
|
||||
TEXT_3,
|
||||
TH_STYLE,
|
||||
edge,
|
||||
tint,
|
||||
} from './consoleUi';
|
||||
@@ -443,89 +450,99 @@ export default function PosStaffPanel({
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border overflow-hidden shadow-sm w-full min-w-0" style={{ borderColor: BORDER }}>
|
||||
<ShellCard className="overflow-hidden w-full min-w-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm whitespace-nowrap" style={{ minWidth: 940 }}>
|
||||
<thead style={TH_STYLE}>
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left">Name</th>
|
||||
<th className="px-4 py-3 text-left">Role</th>
|
||||
<th className="px-4 py-3 text-left">Mobile</th>
|
||||
<th className="px-4 py-3 text-left">Shift</th>
|
||||
<th className="px-4 py-3 text-left">Username</th>
|
||||
<th className="px-4 py-3 text-left">PIN</th>
|
||||
<th className="px-4 py-3 text-left">Password</th>
|
||||
<th className="px-4 py-3 text-left">Status</th>
|
||||
<th className="px-4 py-3 text-right"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y" style={{ borderColor: DIVIDER }}>
|
||||
{/* whiteSpace: the old <table> carried `whitespace-nowrap`; Astryx's
|
||||
Table does not, and without it the till username breaks
|
||||
mid-token ("supervisor.1169@po / s.nearle.in"). The wrapper
|
||||
above already scrolls horizontally. */}
|
||||
<Table style={{ minWidth: 940, whiteSpace: 'nowrap' }}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Role</TableHeaderCell>
|
||||
<TableHeaderCell>Mobile</TableHeaderCell>
|
||||
<TableHeaderCell>Shift</TableHeaderCell>
|
||||
<TableHeaderCell>Username</TableHeaderCell>
|
||||
<TableHeaderCell>PIN</TableHeaderCell>
|
||||
<TableHeaderCell>Password</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell><span className="sr-only">Row actions</span></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{staff.map((s) => {
|
||||
const inactive = (s.status || '').toLowerCase() === 'inactive';
|
||||
const who = s.full_name || `#${s.user_id}`;
|
||||
return (
|
||||
<tr key={s.user_id} className="hover:bg-slate-50/70" style={{ opacity: inactive ? 0.55 : 1 }}>
|
||||
<td className="px-4 py-3 font-bold" style={{ color: TEXT }}>{s.full_name || `#${s.user_id}`}</td>
|
||||
<td className="px-4 py-3">
|
||||
<TableRow key={s.user_id} style={{ opacity: inactive ? 0.55 : 1 }}>
|
||||
<TableCell><span className="font-bold">{who}</span></TableCell>
|
||||
<TableCell>
|
||||
<StatusChip
|
||||
label={s.role || String(s.role_id)}
|
||||
color={s.role_id === POS_ROLE_CASHIER ? BRAND : '#0ea5e9'}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[12px]" style={{ color: s.contactno ? TEXT : '#f59e0b' }}>
|
||||
{s.contactno || 'not set'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: TEXT_2 }}>
|
||||
{s.shift_name ? `${s.shift_name} · ${s.shift_start}–${s.shift_end}` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[12px]" style={{ color: TEXT_2 }}>{s.authname || '—'}</td>
|
||||
<td className="px-4 py-3 font-mono tracking-widest" style={{ color: TEXT_2 }}>{s.pin || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
{s.has_password ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11.5px] font-semibold" style={{ color: '#10b981' }}>
|
||||
<ShieldCheck size={12} /> Set
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[11.5px] font-semibold" style={{ color: '#f59e0b' }}>
|
||||
<AlertTriangle size={12} /> Not set
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-[12px]" style={s.contactno ? undefined : { color: '#f59e0b' }}>
|
||||
{s.contactno || 'not set'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-[12px]" style={{ color: TEXT_2 }}>
|
||||
{s.shift_name ? `${s.shift_name} · ${s.shift_start}–${s.shift_end}` : '—'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell><span className="font-mono text-[12px]" style={{ color: TEXT_2 }}>{s.authname || '—'}</span></TableCell>
|
||||
<TableCell><span className="font-mono tracking-widest" style={{ color: TEXT_2 }}>{s.pin || '—'}</span></TableCell>
|
||||
<TableCell>
|
||||
{/* Was two hand-tinted spans. "Set"/"Not set" is a state,
|
||||
so it reads as success vs warning. */}
|
||||
{s.has_password
|
||||
? <Badge variant="success" label="Set" icon={<ShieldCheck size={12} />} />
|
||||
: <Badge variant="warning" label="Not set" icon={<AlertTriangle size={12} />} />}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusChip label={inactive ? 'Inactive' : 'Active'} color={inactive ? TEXT_3 : '#10b981'} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{inactive ? (
|
||||
<button
|
||||
<Button
|
||||
label="Reactivate"
|
||||
aria-label={`Reactivate ${who}`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isDisabled={updateMut.isPending}
|
||||
onClick={() => reactivate(s)}
|
||||
disabled={updateMut.isPending}
|
||||
className="rounded-full px-3 py-1 text-[11.5px] font-bold cursor-pointer disabled:opacity-50"
|
||||
style={{ background: tint('#10b981'), color: '#10b981', border: `1px solid ${edge('#10b981')}` }}
|
||||
>
|
||||
Reactivate
|
||||
</button>
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<IconBtn title="Edit" color={BRAND} onClick={() => openEdit(s)}>
|
||||
{/* Names, not bare verbs. A staff table repeats
|
||||
"Edit / Reset password / Deactivate" on every
|
||||
row, so without the person's name a screen
|
||||
reader hears the same three words N times with
|
||||
no idea whose account is about to change. */}
|
||||
<IconBtn title={`Edit ${who}`} color={BRAND} onClick={() => openEdit(s)}>
|
||||
<Pencil size={13} />
|
||||
</IconBtn>
|
||||
<IconBtn title="Reset password" color="#0ea5e9" onClick={() => setConfirmReset(s)}>
|
||||
<IconBtn title={`Reset password for ${who}`} color="#0ea5e9" onClick={() => setConfirmReset(s)}>
|
||||
<KeyRound size={13} />
|
||||
</IconBtn>
|
||||
<IconBtn title="Deactivate" color="#ef4444" onClick={() => setConfirmOff(s)}>
|
||||
<IconBtn title={`Deactivate ${who}`} color="#ef4444" onClick={() => setConfirmOff(s)}>
|
||||
<Trash2 size={13} />
|
||||
</IconBtn>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</ShellCard>
|
||||
)}
|
||||
|
||||
{rowError && (
|
||||
@@ -595,37 +612,28 @@ export default function PosStaffPanel({
|
||||
anyone signed in at a counter with it will be refused on their next sign-in.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||
<button
|
||||
<Button
|
||||
label={updateMut.isPending ? 'Resetting…' : 'Reset password'}
|
||||
isLoading={updateMut.isPending}
|
||||
isDisabled={updateMut.isPending}
|
||||
onClick={() => resetPassword(confirmReset)}
|
||||
disabled={updateMut.isPending}
|
||||
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50"
|
||||
style={{ background: '#0ea5e9' }}
|
||||
>
|
||||
{updateMut.isPending ? 'Resetting…' : 'Reset password'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmReset(null)}
|
||||
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
|
||||
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" onClick={() => setConfirmReset(null)} />
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* One-time credentials. The password is not recoverable after this. */}
|
||||
{issued && (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-slate-900/45" onClick={() => setIssued(null)} />
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6" style={{ border: `1px solid ${BORDER}`, minWidth: 'min(20rem, 100%)' }}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<KeyRound size={16} style={{ color: BRAND }} />
|
||||
<h3 className="text-[16px] font-extrabold" style={{ color: TEXT }}>Account created</h3>
|
||||
</div>
|
||||
<p className="text-[12.5px] mb-4" style={{ color: '#b45309' }}>
|
||||
This password is shown once and cannot be looked up again. Copy it now.
|
||||
</p>
|
||||
// PHASE 6 — the last hand-rolled overlay on this page, and the one that
|
||||
// mattered most: it shows a password that exists nowhere else. It now
|
||||
// goes through the same Modal as edit / reset / deactivate, so it gains
|
||||
// Escape and a focus trap like the rest.
|
||||
<Modal onClose={() => setIssued(null)} title="Account created">
|
||||
<div>
|
||||
<Banner status="warning" title="Copy this password now">
|
||||
It is shown once and cannot be looked up again.
|
||||
</Banner>
|
||||
|
||||
<div className="rounded-xl p-3 space-y-2" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
||||
<Field label="Name" value={issued.full_name} />
|
||||
@@ -644,24 +652,19 @@ export default function PosStaffPanel({
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||
<button
|
||||
<Button
|
||||
label={copied ? 'Copied' : 'Copy all'}
|
||||
icon={copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
onClick={copyCredentials}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer"
|
||||
style={{ background: BRAND }}
|
||||
>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
{copied ? 'Copied' : 'Copy all'}
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<Button
|
||||
label={copied ? 'Done' : 'Close without copying'}
|
||||
variant="secondary"
|
||||
onClick={() => setIssued(null)}
|
||||
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
|
||||
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
|
||||
>
|
||||
{copied ? 'Done' : 'Close without copying'}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Uses the same Modal as edit and reset. It was a hand-rolled copy of
|
||||
@@ -674,26 +677,19 @@ export default function PosStaffPanel({
|
||||
deleted, because past bills carry their name.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
<Button
|
||||
label={deactivateMut.isPending ? 'Deactivating…' : 'Deactivate'}
|
||||
variant="destructive"
|
||||
isLoading={deactivateMut.isPending}
|
||||
isDisabled={deactivateMut.isPending}
|
||||
onClick={() =>
|
||||
deactivateMut.mutate(
|
||||
{ tenantid: tenantId, locationid, userid: confirmOff.user_id },
|
||||
{ onSettled: () => setConfirmOff(null) },
|
||||
)
|
||||
}
|
||||
disabled={deactivateMut.isPending}
|
||||
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50 whitespace-nowrap"
|
||||
style={{ background: '#ef4444' }}
|
||||
>
|
||||
{deactivateMut.isPending ? 'Deactivating…' : 'Deactivate'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmOff(null)}
|
||||
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer whitespace-nowrap"
|
||||
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" onClick={() => setConfirmOff(null)} />
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
@@ -702,6 +698,10 @@ export default function PosStaffPanel({
|
||||
}
|
||||
|
||||
const WEEKDAY_LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
|
||||
/** Full names for the day toggles' accessible names. The single letters are
|
||||
* ambiguous by design — two T's and two S's — so "T" alone tells a screen
|
||||
* reader user nothing about which day they just switched on. */
|
||||
const WEEKDAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
|
||||
/**
|
||||
* The shift windows an outlet runs.
|
||||
@@ -827,20 +827,15 @@ function ShiftManager({
|
||||
Days
|
||||
</span>
|
||||
{WEEKDAY_LABELS.map((d, i) => (
|
||||
<button
|
||||
<ToggleButton
|
||||
key={i}
|
||||
onClick={() => toggleDay(i)}
|
||||
className="rounded-full text-[11px] font-extrabold cursor-pointer"
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
background: days[i] ? BRAND : '#fff',
|
||||
color: days[i] ? '#fff' : TEXT_3,
|
||||
border: `1px solid ${days[i] ? BRAND : BORDER}`,
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
label={WEEKDAY_NAMES[i]}
|
||||
isIconOnly
|
||||
icon={<span className="text-[11px] font-extrabold">{d}</span>}
|
||||
size="sm"
|
||||
isPressed={days[i]}
|
||||
onPressedChange={() => toggleDay(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -851,21 +846,13 @@ function ShiftManager({
|
||||
{error && <p className="text-[12px] mt-2 font-semibold" style={{ color: '#ef4444' }}>{error}</p>}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 mt-3">
|
||||
<button
|
||||
<Button
|
||||
label={createMut.isPending ? 'Adding…' : 'Add shift'}
|
||||
isLoading={createMut.isPending}
|
||||
isDisabled={createMut.isPending}
|
||||
onClick={submit}
|
||||
disabled={createMut.isPending}
|
||||
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50"
|
||||
style={{ background: BRAND }}
|
||||
>
|
||||
{createMut.isPending ? 'Adding…' : 'Add shift'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setOpen(false); setError(''); }}
|
||||
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
|
||||
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" onClick={() => { setOpen(false); setError(''); }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -893,7 +880,12 @@ function ShiftManager({
|
||||
: WEEKDAY_LABELS.filter((_, i) => s.weekdays?.[i] === '1').join(' ') || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
label={inactive ? 'Reactivate' : 'Retire'}
|
||||
aria-label={`${inactive ? 'Reactivate' : 'Retire'} the ${s.name} shift`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isDisabled={updateMut.isPending}
|
||||
onClick={() =>
|
||||
updateMut.mutate({
|
||||
tenantid: tenantId,
|
||||
@@ -902,16 +894,7 @@ function ShiftManager({
|
||||
status: inactive ? 'Active' : 'Inactive',
|
||||
})
|
||||
}
|
||||
disabled={updateMut.isPending}
|
||||
className="rounded-full px-3 py-1 text-[11.5px] font-bold cursor-pointer shrink-0 disabled:opacity-50"
|
||||
style={
|
||||
inactive
|
||||
? { background: tint('#10b981'), color: '#10b981', border: `1px solid ${edge('#10b981')}` }
|
||||
: { background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }
|
||||
}
|
||||
>
|
||||
{inactive ? 'Reactivate' : 'Retire'}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -941,15 +924,20 @@ function IconBtn({
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// PHASE 6 — on Astryx IconButton. Like Modal below, converting this one
|
||||
// helper converts every call site (edit / reset password / deactivate) at
|
||||
// once. `title` becomes `label`: it was the only thing naming these buttons,
|
||||
// and a title attribute is not an accessible name — three icon-only controls
|
||||
// per row announced as nothing at all.
|
||||
return (
|
||||
<button
|
||||
<IconButton
|
||||
label={title}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={children}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className="inline-flex items-center justify-center rounded-full cursor-pointer transition-colors"
|
||||
style={{ width: 28, height: 28, background: tint(color), color, border: `1px solid ${edge(color)}` }}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
style={{ color }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import { Search, Plus, Minus, Trash2, Receipt, X, Tag, QrCode, CreditCard, PieChart, Barcode, ShoppingCart, User, RefreshCcw, Save, Download, ChevronRight, Zap, Wallet, Smartphone, AlertCircle } from 'lucide-react';
|
||||
import { Search, Plus, Trash2, Receipt, X, Tag, QrCode, CreditCard, PieChart, Barcode, ShoppingCart, User, RefreshCcw, Save, Download, ChevronRight, Zap, Wallet, Smartphone, AlertCircle } from 'lucide-react';
|
||||
import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
||||
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||
import { useNotify } from './Toasts';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Spinner } from '@astryxdesign/core/Spinner';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||
import {
|
||||
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
|
||||
} from '@astryxdesign/core/Table';
|
||||
|
||||
/**
|
||||
* PHASE 6 — the one modal shell for this screen.
|
||||
@@ -407,6 +418,14 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
.hide-spin-button { -moz-appearance: textfield; }
|
||||
.pos-scan-glow { box-shadow: 0 0 0 3px rgba(16,185,129,0.15), 0 1px 3px rgba(0,0,0,0.08); }
|
||||
.pos-scan-glow-red { box-shadow: 0 0 0 3px rgba(239,68,68,0.15), 0 1px 3px rgba(0,0,0,0.08); }
|
||||
/* The scan field sits inside its own glowing frame, so the input's own
|
||||
chrome is removed and its type size restored. A cashier aims a
|
||||
scanner at this from arm's length — the size is the affordance. */
|
||||
.pos-scan-field input {
|
||||
font-size: 20px; font-weight: 700; background: transparent;
|
||||
border: none; box-shadow: none; height: auto; padding-left: 0;
|
||||
}
|
||||
.pos-scan-field input::placeholder { font-weight: 400; }
|
||||
@keyframes scan-pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
|
||||
.scan-pulse { animation: scan-pulse 1.8s ease-in-out infinite; }
|
||||
`}</style>
|
||||
@@ -427,43 +446,34 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* PHASE 6 — the till's action bar. Refund mode used to restyle every
|
||||
button inline (white-on-red); that state now reads from the Refund
|
||||
button's own variant plus the banner beside the title, so the other
|
||||
four stop changing colour for a mode they do not control. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setShowManualSearch(true)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border transition-colors ${isRefundMode ? 'bg-white/10 hover:bg-white/20 text-white border-white/15' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border-slate-200'}`}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" /> Browse
|
||||
</button>
|
||||
<button
|
||||
<Button label="Browse" variant="secondary" size="sm" icon={<Search className="w-3.5 h-3.5" />} onClick={() => setShowManualSearch(true)} />
|
||||
<Button
|
||||
label="Refund"
|
||||
variant={isRefundMode ? 'destructive' : 'secondary'}
|
||||
size="sm"
|
||||
icon={<RefreshCcw className="w-3.5 h-3.5" />}
|
||||
onClick={() => setIsRefundMode(!isRefundMode)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border transition-colors ${isRefundMode ? 'bg-white text-red-600 border-white' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border-slate-200'}`}
|
||||
>
|
||||
<RefreshCcw className="w-3.5 h-3.5" /> Refund
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<Button
|
||||
label="Park"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Save className="w-3.5 h-3.5" />}
|
||||
isDisabled={cart.length === 0}
|
||||
onClick={parkCart}
|
||||
disabled={cart.length === 0}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border transition-colors disabled:opacity-40 ${isRefundMode ? 'bg-white/10 hover:bg-white/20 text-white border-white/15' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border-slate-200'}`}
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" /> Park
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowParkedPrompt(true)}
|
||||
className={`relative flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border transition-colors ${isRefundMode ? 'bg-white/10 hover:bg-white/20 text-white border-white/15' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border-slate-200'}`}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> Retrieve
|
||||
{parkedCarts.length > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-amber-400 text-slate-900 rounded-full text-[9px] font-black flex items-center justify-center">
|
||||
{parkedCarts.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { checkDailyReset(); setShowReport(true); }}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold rounded-lg border transition-colors ${isRefundMode ? 'bg-white/10 hover:bg-white/20 text-white border-white/15' : 'bg-slate-50 hover:bg-slate-100 text-slate-700 border-slate-200'}`}
|
||||
>
|
||||
<PieChart className="w-3.5 h-3.5" /> Report
|
||||
</button>
|
||||
/>
|
||||
{/* The parked count was a floating dot pinned to the button corner,
|
||||
outside its label — nothing announced it. */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button label="Retrieve" variant="secondary" size="sm" icon={<Download className="w-3.5 h-3.5" />} onClick={() => setShowParkedPrompt(true)} />
|
||||
{parkedCarts.length > 0 && <Badge variant="warning" label={String(parkedCarts.length)} />}
|
||||
</div>
|
||||
<Button label="Report" variant="secondary" size="sm" icon={<PieChart className="w-3.5 h-3.5" />} onClick={() => { checkDailyReset(); setShowReport(true); }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -473,21 +483,26 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
<div className={`scan-pulse ${isRefundMode ? 'text-red-400' : 'text-emerald-500'}`}>
|
||||
<Barcode className="w-7 h-7" />
|
||||
</div>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder={isRefundMode ? 'Scan item to refund…' : 'Scan barcode or type SKU…'}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="flex-1 bg-transparent border-none outline-none font-bold text-xl text-slate-800 placeholder:font-normal placeholder:text-slate-400"
|
||||
autoFocus
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="text-slate-400 hover:text-slate-600">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{/* The scan field keeps its oversized type deliberately — an
|
||||
operator aims a scanner at this from arm's length and the size
|
||||
is the affordance, so `pos-scan-field` restores the 20px bold
|
||||
face that TextInput's `lg` does not reach. What it gains is a
|
||||
real label (it had none at all) and a clear button that is now
|
||||
part of the control rather than a bare <button> beside it. */}
|
||||
<div className="flex-1 pos-scan-field">
|
||||
<TextInput
|
||||
ref={searchInputRef}
|
||||
label={isRefundMode ? 'Scan item to refund' : 'Scan barcode or type SKU'}
|
||||
isLabelHidden
|
||||
size="lg"
|
||||
placeholder={isRefundMode ? 'Scan item to refund…' : 'Scan barcode or type SKU…'}
|
||||
value={search}
|
||||
onChange={(v) => setSearch(v)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
hasClear
|
||||
hasAutoFocus
|
||||
/>
|
||||
</div>
|
||||
{!search && (
|
||||
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-widest hidden md:block">
|
||||
Press Enter
|
||||
@@ -499,95 +514,78 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
{/* Cart Table */}
|
||||
<div className="flex-1 overflow-y-auto bg-white">
|
||||
{stockQ.isLoading ? (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 text-slate-400">
|
||||
<div className="w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm font-semibold">Initializing inventory…</p>
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Spinner size="xl" label="Initializing inventory…" />
|
||||
</div>
|
||||
) : cart.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-5 text-slate-300 select-none">
|
||||
<div className="w-24 h-24 rounded-3xl bg-slate-100 flex items-center justify-center">
|
||||
<ShoppingCart className="w-12 h-12 text-slate-300" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-bold text-slate-400 text-lg">Cart is empty</p>
|
||||
<p className="text-sm text-slate-400 mt-1">Scan a barcode or browse products to add items</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowManualSearch(true)}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-slate-900 text-white text-sm font-bold rounded-xl hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<Search className="w-4 h-4" /> Browse Products
|
||||
</button>
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<EmptyState
|
||||
icon={<ShoppingCart className="w-12 h-12" />}
|
||||
title="Cart is empty"
|
||||
description="Scan a barcode or browse products to add items"
|
||||
actions={<Button label="Browse Products" icon={<Search className="w-4 h-4" />} onClick={() => setShowManualSearch(true)} />}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full overflow-x-auto custom-scrollbar">
|
||||
<table className="w-full text-left border-collapse min-w-[500px]">
|
||||
<thead className="sticky top-0 z-10 bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-5 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500">Product</th>
|
||||
<th className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-center">Qty</th>
|
||||
<th className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-right">Rate</th>
|
||||
<th className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-right">Disc.</th>
|
||||
<th className="px-4 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-right">Amount</th>
|
||||
<th className="px-5 py-3 text-[11px] font-bold uppercase tracking-wider text-slate-500 text-center w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<Table style={{ minWidth: 500 }}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Product</TableHeaderCell>
|
||||
<TableHeaderCell>Qty</TableHeaderCell>
|
||||
<TableHeaderCell>Rate</TableHeaderCell>
|
||||
<TableHeaderCell>Disc.</TableHeaderCell>
|
||||
<TableHeaderCell>Amount</TableHeaderCell>
|
||||
<TableHeaderCell><span className="sr-only">Row actions</span></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cartWithPromos.map((item, idx) => (
|
||||
<tr
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={`group border-b border-slate-100 hover:bg-slate-50/80 transition-colors ${item.isRefund ? 'bg-red-50/50' : ''}`}
|
||||
style={item.isRefund ? { background: 'rgba(244,63,94,0.06)' } : undefined}
|
||||
>
|
||||
<td className="px-5 py-3.5">
|
||||
<TableCell>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<span className="text-[10px] font-black text-slate-500">{idx + 1}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.isRefund && (
|
||||
<span className="px-1.5 py-0.5 bg-red-100 text-red-600 text-[9px] uppercase font-black rounded tracking-wider">
|
||||
Refund
|
||||
</span>
|
||||
)}
|
||||
<span className="font-bold text-slate-900 text-[14px] leading-snug">{item.name}</span>
|
||||
{item.isRefund && <Badge variant="error" label="Refund" />}
|
||||
<span className="font-bold text-[14px] leading-snug">{item.name}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 font-mono mt-0.5">{item.sku}</div>
|
||||
<div className="text-[11px] font-mono mt-0.5 opacity-50">{item.sku}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-center">
|
||||
<div className="inline-flex items-center bg-slate-100 rounded-lg overflow-hidden border border-slate-200">
|
||||
<button
|
||||
onClick={() => updateCartQty(item.id, item.qty - 1)}
|
||||
className="w-8 h-8 flex items-center justify-center text-slate-500 hover:bg-red-50 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Minus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5 px-1">
|
||||
<input
|
||||
type="number"
|
||||
step={item.unit === 'kg' ? '0.001' : '1'}
|
||||
value={item.qty}
|
||||
onChange={(e) => updateCartQty(item.id, parseFloat(e.target.value) || 0)}
|
||||
className="w-12 text-center text-sm font-bold text-slate-900 bg-transparent border-none outline-none hide-spin-button"
|
||||
/>
|
||||
<span className="text-[10px] font-bold text-slate-400">{item.unit}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateCartQty(item.id, item.qty + 1)}
|
||||
disabled={item.qty >= item.maxQty}
|
||||
className="w-8 h-8 flex items-center justify-center text-slate-500 hover:bg-emerald-50 hover:text-emerald-600 transition-colors disabled:opacity-30"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{/* The −/+ pair plus a bare number box is one NumberInput
|
||||
now: it brings its own steppers, so the two icon-only
|
||||
buttons (neither of which had an accessible name) are
|
||||
gone, and `max` finally expresses the stock ceiling that
|
||||
only the + button used to enforce. */}
|
||||
<div className="flex items-center gap-1 w-28">
|
||||
<NumberInput
|
||||
label={`Quantity for ${item.name}`}
|
||||
isLabelHidden
|
||||
size="sm"
|
||||
value={item.qty}
|
||||
onChange={(v) => updateCartQty(item.id, v ?? 0)}
|
||||
step={item.unit === 'kg' ? 0.001 : 1}
|
||||
min={0}
|
||||
max={item.maxQty}
|
||||
/>
|
||||
<span className="text-[10px] font-bold shrink-0 opacity-50">{item.unit}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-right">
|
||||
<span className="font-bold text-slate-700 text-sm">₹{item.price.toFixed(2)}</span>
|
||||
<span className="block text-[10px] text-slate-400">/{item.unit}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 text-right">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-right">
|
||||
<span className="font-bold text-sm">₹{item.price.toFixed(2)}</span>
|
||||
<span className="block text-[10px] opacity-50">/{item.unit}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.discountPerItem > 0 || item.hasBogo ? (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{item.discountPerItem > 0 && (
|
||||
@@ -600,60 +598,66 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
-₹{(item.promoDiscount || 0).toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex gap-1 flex-wrap justify-end">
|
||||
<div className="flex gap-1 flex-wrap justify-end items-center">
|
||||
{item.discountPerItem > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-[9px] font-black text-emerald-700 bg-emerald-100 px-1.5 py-0.5 rounded-md uppercase tracking-wide">
|
||||
{item.couponCode || 'MANUAL'}
|
||||
<button onClick={() => removeCoupon(item.id)} className="ml-0.5 hover:text-red-500">
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{item.hasBogo && (
|
||||
<span className="text-[9px] font-black text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded-md uppercase tracking-wide">
|
||||
BOGO
|
||||
</span>
|
||||
<>
|
||||
<Badge variant="success" label={item.couponCode || 'MANUAL'} />
|
||||
<IconButton
|
||||
label={`Remove discount from ${item.name}`}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<X className="w-3 h-3" />}
|
||||
onClick={() => removeCoupon(item.id)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{item.hasBogo && <Badge variant="warning" label="BOGO" />}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-300 text-sm">—</span>
|
||||
<span className="text-sm block text-right opacity-30">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-4 py-3.5 text-right font-bold text-[15px] ${item.isRefund ? 'text-red-600' : 'text-slate-900'}`}>
|
||||
{item.isRefund ? '-' : ''}₹
|
||||
{(
|
||||
item.price * item.qty -
|
||||
item.discountPerItem * item.qty -
|
||||
(item.promoDiscount || 0)
|
||||
).toFixed(2)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-center">
|
||||
<div className="flex items-center justify-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className={`text-right font-bold text-[15px] ${item.isRefund ? 'text-red-600' : ''}`}>
|
||||
{item.isRefund ? '-' : ''}₹
|
||||
{(
|
||||
item.price * item.qty -
|
||||
item.discountPerItem * item.qty -
|
||||
(item.promoDiscount || 0)
|
||||
).toFixed(2)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{/* The `opacity-0 group-hover` reveal is gone. On a till
|
||||
these two are the only way to fix a mis-scan, and they
|
||||
were invisible until the mouse happened to be over the
|
||||
row — while still being in the tab order. */}
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<IconButton
|
||||
label={`Apply discount to ${item.name}`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Tag className="w-3.5 h-3.5" />}
|
||||
onClick={() => {
|
||||
setDiscountPromptItem(item.id);
|
||||
setCouponCodeInput(item.couponCode || '');
|
||||
setDiscountAmountInput(item.discountPerItem > 0 ? item.discountPerItem.toString() : '');
|
||||
}}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-50 text-blue-500 hover:bg-blue-100 transition-colors"
|
||||
title="Apply Discount"
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<IconButton
|
||||
label={`Remove ${item.name} from cart`}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
onClick={() => removeCartItem(item.id)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg bg-red-50 text-red-500 hover:bg-red-100 transition-colors"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -683,21 +687,26 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
<div className="flex items-center gap-1.5 justify-end">
|
||||
<User className="w-3 h-3 text-blue-500" />
|
||||
<span className="text-xs font-bold text-slate-900">{customer.name || customer.phone}</span>
|
||||
<button onClick={() => setCustomer(null)} className="text-slate-400 hover:text-slate-600 ml-1">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
<IconButton
|
||||
label="Detach customer from this sale"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<X className="w-3 h-3" />}
|
||||
onClick={() => setCustomer(null)}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] font-bold text-amber-500 mt-0.5 flex items-center justify-end gap-1">
|
||||
<Zap className="w-2.5 h-2.5" /> {customer.points} pts
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
label="Customer"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Plus className="w-3.5 h-3.5" />}
|
||||
onClick={() => setShowCustomerPrompt(true)}
|
||||
className="flex items-center gap-1.5 bg-slate-50 hover:bg-slate-100 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> Customer
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -761,14 +770,19 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
{/* PAY keeps its own emerald face and full width: it is the one
|
||||
control the whole screen exists to reach, and it is hit hundreds of
|
||||
times a shift. Everything around it is Astryx; this stays a
|
||||
deliberate exception, marked as one. */}
|
||||
<div className="flex gap-2 pos-pay-row">
|
||||
<IconButton
|
||||
label="Clear cart"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
icon={<Trash2 className="w-5 h-5" />}
|
||||
isDisabled={cart.length === 0}
|
||||
onClick={clearCart}
|
||||
disabled={cart.length === 0}
|
||||
className="w-12 h-12 flex items-center justify-center bg-white border border-slate-200 hover:bg-red-50 hover:border-red-200 text-slate-500 hover:text-red-500 rounded-xl transition-all disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
/>
|
||||
<button
|
||||
onClick={handleCheckoutBtn}
|
||||
disabled={cart.length === 0 || !customer}
|
||||
@@ -787,18 +801,18 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
<h3 className="font-bold text-slate-900 flex items-center gap-2">
|
||||
<Search className="w-5 h-5 text-indigo-500" /> Product Search
|
||||
</h3>
|
||||
<button onClick={() => setShowManualSearch(false)} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<IconButton label="Close product search" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setShowManualSearch(false)} />
|
||||
</div>
|
||||
<div className="px-5 py-3 border-b border-slate-100">
|
||||
<input
|
||||
type="text"
|
||||
<TextInput
|
||||
label="Search products"
|
||||
isLabelHidden
|
||||
placeholder="Search by name, SKU…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
className="w-full px-4 py-2.5 bg-slate-50 border border-slate-200 rounded-xl text-sm font-medium text-slate-800 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-400/20 transition-all"
|
||||
onChange={(v) => setSearch(v)}
|
||||
startIcon={<Search className="w-4 h-4" />}
|
||||
hasClear
|
||||
hasAutoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
@@ -810,6 +824,10 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
p.sku.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
.map((product) => (
|
||||
// Stays a native button: it is a full-width result row whose
|
||||
// whole surface is the hit target, which Button's fixed
|
||||
// padding and centred label cannot express. It already had a
|
||||
// disabled state and a text label, so nothing is lost.
|
||||
<button
|
||||
key={product.id}
|
||||
disabled={product.closing <= 0}
|
||||
@@ -840,52 +858,52 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
<div className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center">
|
||||
<User className="w-6 h-6 text-blue-500" />
|
||||
</div>
|
||||
<button onClick={closeCustomerPrompt} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<IconButton label="Close customer panel" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={closeCustomerPrompt} />
|
||||
</div>
|
||||
<h3 className="font-bold text-xl text-slate-900 mb-1">{isNewCustomer ? 'New Customer' : 'Attach Customer'}</h3>
|
||||
<p className="text-sm text-slate-500 mb-5">{isNewCustomer ? "Please enter the customer's name." : 'Enter mobile number to retrieve loyalty profile.'}</p>
|
||||
|
||||
<input
|
||||
type="tel"
|
||||
placeholder="e.g. 9876543210"
|
||||
value={customerPhoneInput}
|
||||
onChange={(e) => setCustomerPhoneInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isNewCustomer && checkCustomerPhone()}
|
||||
disabled={isNewCustomer}
|
||||
className={`w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-slate-800 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-all ${isNewCustomer ? 'opacity-50 mb-3' : 'mb-4'}`}
|
||||
autoFocus={!isNewCustomer}
|
||||
/>
|
||||
{/* Was `type="tel"` with no filtering, so letters reached a field
|
||||
the Find button gates on `.length >= 10` — "abcdefghij" opened
|
||||
a lookup. Astryx has no tel type; digits-only in the handler is
|
||||
the stricter fix anyway. */}
|
||||
<div className="mb-4">
|
||||
<TextInput
|
||||
label="Customer mobile number"
|
||||
placeholder="e.g. 9876543210"
|
||||
value={customerPhoneInput}
|
||||
onChange={(v) => setCustomerPhoneInput(v.replace(/\D/g, '').slice(0, 10))}
|
||||
onEnter={() => { if (!isNewCustomer) checkCustomerPhone(); }}
|
||||
isDisabled={isNewCustomer}
|
||||
hasAutoFocus={!isNewCustomer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isNewCustomer && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Customer Name"
|
||||
value={customerNameInput}
|
||||
onChange={(e) => setCustomerNameInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && saveNewCustomer()}
|
||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-slate-800 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-400/20 transition-all mb-4"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="mb-4">
|
||||
<TextInput
|
||||
label="Customer name"
|
||||
placeholder="Customer Name"
|
||||
value={customerNameInput}
|
||||
onChange={(v) => setCustomerNameInput(v)}
|
||||
onEnter={saveNewCustomer}
|
||||
hasAutoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewCustomer ? (
|
||||
<button
|
||||
<Button
|
||||
label="Save & Attach"
|
||||
isDisabled={!customerNameInput.trim()}
|
||||
onClick={saveNewCustomer}
|
||||
disabled={!customerNameInput.trim()}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-200 disabled:text-slate-400 text-white rounded-xl font-bold transition-colors"
|
||||
>
|
||||
Save & Attach
|
||||
</button>
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
label="Find & Attach"
|
||||
isDisabled={customerPhoneInput.length < 10}
|
||||
onClick={checkCustomerPhone}
|
||||
disabled={customerPhoneInput.length < 10}
|
||||
className="w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-200 disabled:text-slate-400 text-white rounded-xl font-bold transition-colors"
|
||||
>
|
||||
Find & Attach
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PosModal>
|
||||
@@ -898,16 +916,11 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
<h3 className="font-bold text-slate-900 flex items-center gap-2">
|
||||
<Download className="w-5 h-5 text-blue-500" /> Parked Sales
|
||||
</h3>
|
||||
<button onClick={() => setShowParkedPrompt(false)} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<IconButton label="Close parked sales" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setShowParkedPrompt(false)} />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
{parkedCarts.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<Save className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm font-medium">No parked sales</p>
|
||||
</div>
|
||||
<EmptyState isCompact icon={<Save className="w-10 h-10" />} title="No parked sales" />
|
||||
) : (
|
||||
parkedCarts.map((pc) => (
|
||||
<div key={pc.id} className="bg-slate-50 border border-slate-200 rounded-xl p-4 flex justify-between items-center hover:border-blue-200 transition-colors">
|
||||
@@ -917,12 +930,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
{new Date(pc.time).toLocaleTimeString()} · {pc.cart.length} items · {pc.customer ? pc.customer.phone : 'Guest'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => retrieveCart(pc)}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm font-bold rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Retrieve
|
||||
</button>
|
||||
<Button label="Retrieve" size="sm" onClick={() => retrieveCart(pc)} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
@@ -938,47 +946,28 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
<div className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center">
|
||||
<Tag className="w-6 h-6 text-emerald-600" />
|
||||
</div>
|
||||
<button onClick={() => setDiscountPromptItem(null)} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<IconButton label="Close discount panel" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setDiscountPromptItem(null)} />
|
||||
</div>
|
||||
<h3 className="font-bold text-xl text-slate-900 mb-1">Apply Discount</h3>
|
||||
<p className="text-sm text-slate-500 mb-5">Enter a manual discount or attach a coupon code.</p>
|
||||
<div className="space-y-3 mb-5">
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-500 uppercase tracking-wider mb-1.5">
|
||||
Discount Amount (₹)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
value={discountAmountInput}
|
||||
onChange={(e) => setDiscountAmountInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyDiscountToItem()}
|
||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-slate-800 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 transition-all"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-500 uppercase tracking-wider mb-1.5">
|
||||
Coupon Code (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. OFF10"
|
||||
value={couponCodeInput}
|
||||
onChange={(e) => setCouponCodeInput(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyDiscountToItem()}
|
||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-slate-800 uppercase outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<NumberInput
|
||||
label="Discount Amount (₹)"
|
||||
value={discountAmountInput === '' ? null : Number(discountAmountInput)}
|
||||
onChange={(v) => setDiscountAmountInput(v == null ? '' : String(v))}
|
||||
min={0}
|
||||
step={0.01}
|
||||
/>
|
||||
<TextInput
|
||||
label="Coupon Code"
|
||||
isOptional
|
||||
placeholder="e.g. OFF10"
|
||||
value={couponCodeInput}
|
||||
onChange={(v) => setCouponCodeInput(v.toUpperCase())}
|
||||
onEnter={applyDiscountToItem}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={applyDiscountToItem}
|
||||
className="w-full py-3 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold transition-colors"
|
||||
>
|
||||
Confirm Discount
|
||||
</button>
|
||||
<Button label="Confirm Discount" onClick={applyDiscountToItem} />
|
||||
</div>
|
||||
</PosModal>
|
||||
)}
|
||||
@@ -994,16 +983,27 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
</p>
|
||||
<p className="text-4xl font-black tracking-tight text-slate-900">₹{Math.abs(grandTotal).toFixed(2)}</p>
|
||||
</div>
|
||||
<button onClick={() => setShowPayment(false)} className="w-8 h-8 flex items-center justify-center rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-500 mt-1">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<IconButton label="Close payment panel" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setShowPayment(false)} />
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Payment Method Tabs */}
|
||||
<div>
|
||||
<p className="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2.5">Payment Method</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{/* SegmentedControl, not three buttons and not SelectableCard.
|
||||
This is a single-select, and as plain buttons the active
|
||||
method lived only in border colour. SelectableCard was tried
|
||||
first and rejected: verified in Chrome, it renders a
|
||||
focusable div carrying only `aria-label` — no role, no
|
||||
aria-checked — and it drops a `role`/`aria-checked` you pass
|
||||
it, so the choice stayed silent. SegmentedControl emits a
|
||||
real radiogroup with arrow-key navigation. */}
|
||||
<SegmentedControl
|
||||
label="Payment method"
|
||||
layout="fill"
|
||||
value={payMethod}
|
||||
onChange={(v) => setPayMethod(v as any)}
|
||||
>
|
||||
{(grandTotal >= 0 ? ['cash', 'upi', 'card'] : ['cash', 'store_credit']).map((m) => {
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
cash: <Wallet className="w-4 h-4" />,
|
||||
@@ -1014,38 +1014,22 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
const labels: Record<string, string> = {
|
||||
cash: 'Cash', upi: 'UPI', card: 'Card', store_credit: 'Store Credit',
|
||||
};
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setPayMethod(m as any)}
|
||||
className={`flex flex-col items-center gap-1.5 py-3 rounded-xl border-2 font-bold text-sm transition-all ${
|
||||
payMethod === m
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-500 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{icons[m]}
|
||||
<span className="text-[11px]">{labels[m]}</span>
|
||||
</button>
|
||||
);
|
||||
return <SegmentedControlItem key={m} value={m} label={labels[m]} icon={icons[m]} />;
|
||||
})}
|
||||
</div>
|
||||
</SegmentedControl>
|
||||
</div>
|
||||
|
||||
{/* Cash Payment */}
|
||||
{payMethod === 'cash' && grandTotal >= 0 && (
|
||||
<div className="space-y-4 animate-in slide-in-from-bottom-2 duration-200">
|
||||
<div>
|
||||
<label className="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2">
|
||||
Cash Received
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={cashReceived}
|
||||
onChange={(e) => setCashReceived(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-50 border-2 border-slate-200 rounded-xl font-black text-2xl text-slate-900 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 transition-all text-right"
|
||||
placeholder="0.00"
|
||||
autoFocus
|
||||
<div className="pos-cash-field">
|
||||
<NumberInput
|
||||
label="Cash Received"
|
||||
value={cashReceived === '' ? null : Number(cashReceived)}
|
||||
onChange={(v) => setCashReceived(v == null ? '' : String(v))}
|
||||
min={0}
|
||||
step={0.01}
|
||||
hasAutoFocus
|
||||
/>
|
||||
</div>
|
||||
{/* Quick denominations */}
|
||||
@@ -1056,24 +1040,20 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
.filter((d) => d >= grandTotal * 0.5)
|
||||
.slice(0, 6)
|
||||
.map((d) => (
|
||||
<button
|
||||
<Button
|
||||
key={d}
|
||||
label={`₹${d}`}
|
||||
size="sm"
|
||||
variant={parseFloat(cashReceived) === d ? 'primary' : 'secondary'}
|
||||
onClick={() => setCashReceived(d.toString())}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold border transition-colors ${
|
||||
parseFloat(cashReceived) === d
|
||||
? 'bg-slate-900 text-white border-slate-900'
|
||||
: 'bg-slate-50 text-slate-700 border-slate-200 hover:border-slate-400'
|
||||
}`}
|
||||
>
|
||||
₹{d}
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
<Button
|
||||
label="Exact"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setCashReceived(Math.ceil(grandTotal).toString())}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-bold border bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100"
|
||||
>
|
||||
Exact
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{cashReceived && parseFloat(cashReceived) >= grandTotal && (
|
||||
@@ -1105,12 +1085,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
<div className="text-center p-5 border-2 border-dashed border-red-200 bg-red-50 rounded-xl">
|
||||
<AlertCircle className="w-8 h-8 text-red-500 mx-auto mb-2" />
|
||||
<p className="text-sm font-bold text-red-600 mb-3">Attach a customer to issue Store Credit</p>
|
||||
<button
|
||||
<Button
|
||||
label="Add Customer"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => { setShowPayment(false); setShowCustomerPrompt(true); }}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-bold hover:bg-red-700"
|
||||
>
|
||||
Add Customer
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1136,6 +1116,8 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Same exception as PAY, and for the same reason: this is the
|
||||
commit point of the transaction. */}
|
||||
<button
|
||||
onClick={confirmPayment}
|
||||
disabled={payMethod === 'store_credit' && !customer}
|
||||
@@ -1154,9 +1136,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
<h3 className="font-bold text-slate-900 flex items-center gap-2">
|
||||
<Receipt className="w-4 h-4" /> Receipt
|
||||
</h3>
|
||||
<button onClick={resetPos} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<IconButton label="Close receipt" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={resetPos} />
|
||||
</div>
|
||||
|
||||
<div className="p-6 font-mono text-sm text-black print:p-0">
|
||||
@@ -1205,15 +1185,8 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 border-t border-slate-100 bg-slate-50 flex gap-3 print:hidden">
|
||||
<button onClick={resetPos} className="flex-1 py-3 bg-white border border-slate-200 text-slate-700 rounded-xl font-bold hover:bg-slate-50 transition-colors">
|
||||
New Sale
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="flex-1 py-3 bg-slate-900 text-white rounded-xl font-bold flex items-center justify-center gap-2 hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<Receipt className="w-4 h-4" /> Print
|
||||
</button>
|
||||
<Button label="New Sale" variant="secondary" onClick={resetPos} />
|
||||
<Button label="Print" icon={<Receipt className="w-4 h-4" />} onClick={() => window.print()} />
|
||||
</div>
|
||||
</PosModal>
|
||||
)}
|
||||
@@ -1225,9 +1198,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
<h3 className="font-bold text-slate-900 flex items-center gap-2">
|
||||
<PieChart className="w-5 h-5 text-emerald-500" /> Daily Sales Report
|
||||
</h3>
|
||||
<button onClick={() => setShowReport(false)} className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 text-slate-400">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<IconButton label="Close daily report" variant="ghost" size="sm" icon={<X className="w-4 h-4" />} onClick={() => setShowReport(false)} />
|
||||
</div>
|
||||
|
||||
<div className="p-5 max-h-[70vh] overflow-y-auto space-y-5">
|
||||
|
||||
@@ -7,15 +7,12 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Building2,
|
||||
Store,
|
||||
Truck,
|
||||
CreditCard,
|
||||
SlidersHorizontal,
|
||||
Users,
|
||||
MapPin,
|
||||
Phone,
|
||||
Mail,
|
||||
Plus,
|
||||
Bike
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { useFiestaAllTenants, useFiestaTenantLocations, useFiestaUpdateLocation, useFiestaDeleteLocation } from '../services/fiestaQueries';
|
||||
import { useAppRoles } from '../services/queries';
|
||||
@@ -26,9 +23,47 @@ import AdminConsole from './AdminConsole';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import type { AuthUser } from '../services/auth';
|
||||
import { useNotify } from './Toasts';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Heading } from '@astryxdesign/core/Heading';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Spinner } from '@astryxdesign/core/Spinner';
|
||||
|
||||
type TabKey = 'profile' | 'outlets' | 'users';
|
||||
|
||||
/**
|
||||
* PHASE 6 — the edit-outlet modal's shell.
|
||||
*
|
||||
* It was a `fixed inset-0` with a hand-set 450px card and a `✕` character for a
|
||||
* close button. It did not close on Escape and did not trap focus, so tabbing
|
||||
* out of the outlet form walked into the store directory behind it.
|
||||
*/
|
||||
function SettingsModal({
|
||||
onClose,
|
||||
width,
|
||||
children,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
width: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => { if (!open) onClose(); }}
|
||||
width={width}
|
||||
maxHeight="90vh"
|
||||
purpose="info"
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Locally-persisted merchant preferences (survive reload via localStorage). */
|
||||
interface MerchantSettings {
|
||||
// Business profile (seeded from live tenant data, then locally editable)
|
||||
@@ -300,18 +335,13 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
||||
const Icon = t.icon;
|
||||
const active = activeTab === t.key;
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={t.key}
|
||||
label={t.label}
|
||||
variant={active ? 'primary' : 'ghost'}
|
||||
icon={<Icon size={16} />}
|
||||
onClick={() => setActiveTab(t.key)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-bold transition-all duration-200 whitespace-nowrap cursor-pointer border-none ${
|
||||
active
|
||||
? 'bg-purple-50 text-[#662582] shadow-sm border-l-2 border-purple-650'
|
||||
: 'text-slate-600 hover:text-slate-900 hover:bg-white bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} className={active ? 'text-[#662582]' : 'text-slate-450'} />
|
||||
<span>{t.label}</span>
|
||||
</button>
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
@@ -388,29 +418,27 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
||||
<div className="space-y-sm">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">Customer Support & Contacts</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-md mt-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px] flex items-center gap-1.5">
|
||||
<Mail size={12} className="text-slate-400" /> Support Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={user?.email || fstr(tenant?.primaryemail) || ''}
|
||||
readOnly
|
||||
className="w-full border border-slate-200 rounded-xl py-3 px-4 bg-slate-50/40 outline-none transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
||||
placeholder="—"
|
||||
/>
|
||||
{/* PHASE 6 — both were `readOnly` inputs styled to look like
|
||||
fields: focusable controls in the tab order that could never
|
||||
be edited, sitting in a section the comment above already
|
||||
calls "read-only". They are values, so they read as values.
|
||||
Every other card in this panel already displays live tenant
|
||||
data exactly this way. */}
|
||||
<div className="p-4 bg-white rounded-xl border border-slate-200/60 shadow-sm">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-black tracking-wider flex items-center gap-1.5">
|
||||
<Mail size={12} /> Support Email
|
||||
</span>
|
||||
<p className="font-bold text-slate-800 text-sm mt-1 break-all">
|
||||
{user?.email || fstr(tenant?.primaryemail) || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px] flex items-center gap-1.5">
|
||||
<Phone size={12} className="text-slate-400" /> Phone Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={user?.contactno || fstr(tenant?.primarycontact) || ''}
|
||||
readOnly
|
||||
className="w-full border border-slate-200 rounded-xl py-3 px-4 bg-slate-50/40 outline-none transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
||||
placeholder="—"
|
||||
/>
|
||||
<div className="p-4 bg-white rounded-xl border border-slate-200/60 shadow-sm">
|
||||
<span className="text-[10px] text-slate-400 uppercase font-black tracking-wider flex items-center gap-1.5">
|
||||
<Phone size={12} /> Phone Number
|
||||
</span>
|
||||
<p className="font-bold text-slate-800 text-sm mt-1 font-mono">
|
||||
{user?.contactno || fstr(tenant?.primarycontact) || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -429,13 +457,12 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
label={showStoreOnboarding ? 'View Store Directory' : 'Add Store Branch'}
|
||||
icon={showStoreOnboarding ? undefined : <Plus size={14} />}
|
||||
onClick={() => setShowStoreOnboarding(!showStoreOnboarding)}
|
||||
className="bg-[#662582] hover:bg-purple-800 text-white px-4 py-2.5 rounded-xl text-xs font-bold uppercase tracking-wider flex items-center gap-1.5 cursor-pointer shadow-sm active:scale-95 transition-all border-none"
|
||||
>
|
||||
{showStoreOnboarding ? 'View Store Directory' : '+ Add Store Branch'}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showStoreOnboarding ? (
|
||||
@@ -445,9 +472,14 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
||||
) : (
|
||||
<>
|
||||
{locationsQ.isLoading ? (
|
||||
<div className="text-center py-lg text-slate-400 font-medium text-sm">Loading live outlets…</div>
|
||||
<div className="py-lg flex justify-center"><Spinner label="Loading live outlets…" /></div>
|
||||
) : cleanOutlets.length === 0 ? (
|
||||
<div className="text-center py-lg text-slate-400 font-medium text-sm">No outlets configured yet.</div>
|
||||
<EmptyState
|
||||
icon={<Store size={26} />}
|
||||
title="No outlets configured yet"
|
||||
description="Add your first store branch to start taking orders."
|
||||
actions={<Button label="Add Store Branch" icon={<Plus size={14} />} onClick={() => setShowStoreOnboarding(true)} />}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-md max-h-[38rem] overflow-y-auto pr-1 scrollbar-thin">
|
||||
{cleanOutlets.map((loc, i) => (
|
||||
@@ -467,31 +499,33 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Both actions read only "Edit"/"Delete" across a
|
||||
grid of outlets — the same two words repeated
|
||||
with no indication which store they act on. */}
|
||||
<div className="flex items-center justify-end gap-2 flex-wrap shrink-0">
|
||||
<span className={`shrink-0 px-2.5 py-1 rounded-full text-[9px] font-black uppercase tracking-wider ${
|
||||
loc.status.toLowerCase() === 'active'
|
||||
? 'text-emerald-700 bg-emerald-50 border border-emerald-100/50'
|
||||
: 'text-zinc-555 bg-zinc-100'
|
||||
}`}>
|
||||
{loc.status || '—'}
|
||||
</span>
|
||||
<button
|
||||
<Badge
|
||||
variant={loc.status.toLowerCase() === 'active' ? 'success' : 'neutral'}
|
||||
label={loc.status || '—'}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
label="Edit"
|
||||
aria-label={`Edit ${loc.locationname}`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingStore({ ...loc });
|
||||
setShowEditStoreModal(true);
|
||||
}}
|
||||
className="text-[10px] uppercase font-bold text-purple-600 hover:text-purple-800 hover:bg-purple-50 px-2 py-1 rounded border border-purple-200 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
label="Delete"
|
||||
aria-label={`Delete ${loc.locationname}`}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteStore(loc)}
|
||||
className="text-[10px] uppercase font-bold text-red-600 hover:text-red-800 hover:bg-red-50 px-2 py-1 rounded border border-red-200 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -534,23 +568,24 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
||||
|
||||
{/* EDIT STORE MODAL */}
|
||||
{showEditStoreModal && editingStore && (
|
||||
<div
|
||||
className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-sm z-[200] flex items-center justify-center p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowEditStoreModal(false); }}
|
||||
>
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl w-[90vw] sm:w-[450px] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="p-4 border-b border-[#e2e8f0] bg-[#f8fafc] flex justify-between items-center shrink-0">
|
||||
<h4 className="font-bold text-[#0f172a] text-sm flex items-center gap-2">
|
||||
<Store size={16} className="text-[#662582]" />
|
||||
Edit Store Outlet Location
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
<SettingsModal onClose={() => setShowEditStoreModal(false)} width={450}>
|
||||
<div className="flex flex-col max-h-[90vh]">
|
||||
<div className="p-4 flex justify-between items-center shrink-0">
|
||||
<Heading level={3}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Store size={16} className="text-[#662582]" />
|
||||
Edit Store Outlet Location
|
||||
</span>
|
||||
</Heading>
|
||||
{/* Was a literal "✕" character in a bare button, so it
|
||||
announced as "multiplication x" and carried no name. */}
|
||||
<IconButton
|
||||
label="Close edit outlet"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<X size={16} />}
|
||||
onClick={() => setShowEditStoreModal(false)}
|
||||
className="text-zinc-400 hover:text-zinc-700 bg-zinc-100 hover:bg-zinc-200 p-1.5 rounded-lg transition-colors cursor-pointer border-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUpdateStoreSubmit} className="flex flex-col flex-1 min-h-0">
|
||||
@@ -566,87 +601,67 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">OUTLET NAME (*)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Coimbatore Main"
|
||||
value={editingStore.locationname}
|
||||
onChange={(e) => setEditingStore({ ...editingStore, locationname: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">LOCAL ZONE AREA (*)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. RS Puram"
|
||||
value={editingStore.suburb}
|
||||
onChange={(e) => setEditingStore({ ...editingStore, suburb: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">CITY</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Coimbatore"
|
||||
value={editingStore.city}
|
||||
onChange={(e) => setEditingStore({ ...editingStore, city: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">CONTACT PHONE (*)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 9876543210"
|
||||
value={editingStore.contactno}
|
||||
onChange={(e) => setEditingStore({ ...editingStore, contactno: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">STATUS</label>
|
||||
<select
|
||||
value={editingStore.status}
|
||||
onChange={(e) => setEditingStore({ ...editingStore, status: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#662582] text-xs"
|
||||
>
|
||||
<option value="Active">Active</option>
|
||||
<option value="Inactive">Inactive</option>
|
||||
<option value="Suspended">Suspended</option>
|
||||
</select>
|
||||
</div>
|
||||
<TextInput
|
||||
label="Outlet name" isRequired
|
||||
placeholder="e.g. Coimbatore Main"
|
||||
value={editingStore.locationname}
|
||||
onChange={(v) => setEditingStore({ ...editingStore, locationname: v })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Local zone area" isRequired
|
||||
placeholder="e.g. RS Puram"
|
||||
value={editingStore.suburb}
|
||||
onChange={(v) => setEditingStore({ ...editingStore, suburb: v })}
|
||||
/>
|
||||
<TextInput
|
||||
label="City"
|
||||
placeholder="e.g. Coimbatore"
|
||||
value={editingStore.city}
|
||||
onChange={(v) => setEditingStore({ ...editingStore, city: v })}
|
||||
/>
|
||||
{/* The phone field accepted anything at all. Its sibling in
|
||||
AdminConsole already strips non-digits and caps at 10; this
|
||||
one edits the same column and did neither. */}
|
||||
<TextInput
|
||||
label="Contact phone" isRequired
|
||||
placeholder="e.g. 9876543210"
|
||||
value={editingStore.contactno}
|
||||
onChange={(v) => setEditingStore({ ...editingStore, contactno: v.replace(/\D/g, '').slice(0, 10) })}
|
||||
startIcon={<Phone size={14} />}
|
||||
/>
|
||||
{/* Option values are lower-case because that is what the
|
||||
column actually holds — verified against gettenantlocations:
|
||||
every outlet reads "active", not "Active". The old options
|
||||
were capitalised, so the select never matched the stored
|
||||
value and showed nothing selected; saving then wrote
|
||||
whatever it resolved to, which could flip a live store's
|
||||
status without anyone choosing it. Labels stay capitalised
|
||||
for display. */}
|
||||
<Selector
|
||||
label="Status"
|
||||
value={String(editingStore.status || '').toLowerCase()}
|
||||
onChange={(v) => setEditingStore({ ...editingStore, status: v || 'active' })}
|
||||
options={[
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'inactive', label: 'Inactive' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#f1f5f9] flex justify-end gap-3 bg-[#f8fafc] shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEditStoreModal(false)}
|
||||
className="px-4 py-2 border border-[#e2e8f0] rounded-lg font-semibold text-zinc-500 hover:bg-zinc-50 cursor-pointer text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
<div className="p-4 flex justify-end gap-3 shrink-0">
|
||||
<Button type="button" label="Cancel" variant="secondary" onClick={() => setShowEditStoreModal(false)} />
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateLocationMut.isPending}
|
||||
className="px-4 py-2 bg-[#662582] hover:bg-purple-800 text-white rounded-lg font-bold shadow-sm cursor-pointer border-none flex items-center gap-2 text-xs"
|
||||
>
|
||||
{updateLocationMut.isPending ? 'Updating...' : 'Update Outlet Profile'}
|
||||
</button>
|
||||
label={updateLocationMut.isPending ? 'Updating…' : 'Update Outlet Profile'}
|
||||
isLoading={updateLocationMut.isPending}
|
||||
isDisabled={updateLocationMut.isPending}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsModal>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useFiestaStockStatement, useFiestaCreateStockRequest, useFiestaGetStock
|
||||
import { num as fnum, productImages, str as fstr, type Row } from '../services/fiestaApi';
|
||||
import { useStoreCatalogue, isPublishedItem } from '../services/storeCatalogue';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi';
|
||||
import { SlideDrawer, Card as ShellCard } from './consoleUi';
|
||||
import { CardCarousel, ProductGallery } from './CatalogueGallery';
|
||||
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
||||
import CatalogueDetailPanel from './CatalogueDetailPanel';
|
||||
@@ -39,6 +39,43 @@ import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { DateInput } from '@astryxdesign/core/DateInput';
|
||||
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||
import {
|
||||
Table, TableHeader, TableHeaderCell, TableBody, TableRow, TableCell,
|
||||
} from '@astryxdesign/core/Table';
|
||||
|
||||
/**
|
||||
* PHASE 6 — the quantity modal's shell.
|
||||
*
|
||||
* It was a `fixed inset-0` with its own backdrop div and a hand-set 340px card.
|
||||
* It did not close on Escape and did not trap focus, so tabbing out of the
|
||||
* quantity field walked into the product grid behind it.
|
||||
*/
|
||||
function CatalogueModal({
|
||||
onClose,
|
||||
width,
|
||||
children,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
width: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => { if (!open) onClose(); }}
|
||||
width={width}
|
||||
maxHeight="90vh"
|
||||
purpose="info"
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
|
||||
|
||||
@@ -374,49 +411,44 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
|
||||
{/* Tabs and Controls */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 w-full">
|
||||
<div className="flex items-center gap-1 bg-zinc-100/80 p-1 rounded-xl border border-zinc-200/60 w-full sm:w-auto sm:inline-flex">
|
||||
<button
|
||||
onClick={() => setView('catalogue')}
|
||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
view === 'catalogue' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||
}`}
|
||||
{/* PHASE 6 — the three view buttons were a hand-built segmented control:
|
||||
they looked like one but announced as three unrelated buttons with no
|
||||
indication which view was active. SegmentedControl is the real thing,
|
||||
with a radiogroup and arrow-key navigation. */}
|
||||
<div className="w-full sm:w-auto">
|
||||
<SegmentedControl
|
||||
label="Catalogue view"
|
||||
value={view}
|
||||
onChange={(v) => setView(v as typeof view)}
|
||||
>
|
||||
<Boxes size={14} /> Browse Catalogue ({products.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('inventory')}
|
||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
view === 'inventory' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Store size={14} /> My Store Inventory ({inventory.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('requests')}
|
||||
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
view === 'requests' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Inbox size={14} /> My Requests ({Object.keys(picks).length})
|
||||
</button>
|
||||
<SegmentedControlItem value="catalogue" label={`Browse Catalogue (${products.length})`} icon={<Boxes size={14} />} />
|
||||
<SegmentedControlItem value="inventory" label={`My Store Inventory (${inventory.length})`} icon={<Store size={14} />} />
|
||||
<SegmentedControlItem value="requests" label={`My Requests (${Object.keys(picks).length})`} icon={<Inbox size={14} />} />
|
||||
</SegmentedControl>
|
||||
</div>
|
||||
|
||||
{/* Counter sales. Deliberately outside the tab group and always visible:
|
||||
it is an action, not another way of looking at the catalogue, and the
|
||||
store needs it from wherever they are on this page. No outlet picker
|
||||
is passed — a store user can only ever import against their own. */}
|
||||
<button
|
||||
onClick={() => setShowOfflineSales(true)}
|
||||
disabled={!storeLocationId}
|
||||
{/* The `title` moves to the wrapper. A disabled control emits no pointer
|
||||
events, so on the button itself the tooltip explaining *why* it was
|
||||
disabled never appeared — on the one state that needed it. Button has
|
||||
no `disabledMessage` (that is a Selector/RadioList prop). */}
|
||||
<span
|
||||
title={
|
||||
storeLocationId
|
||||
? 'Upload an Excel sheet of counter sales to reduce your stock'
|
||||
: 'Your store outlet is still loading'
|
||||
}
|
||||
className="flex items-center justify-center gap-1.5 shrink-0 px-3 py-2 rounded-xl text-xs font-bold text-white bg-[#662582] hover:bg-[#531e6a] transition shadow-sm cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed w-full sm:w-auto"
|
||||
>
|
||||
<UploadCloud size={14} /> Upload Offline Sales
|
||||
</button>
|
||||
<Button
|
||||
label="Upload Offline Sales"
|
||||
icon={<UploadCloud size={14} />}
|
||||
isDisabled={!storeLocationId}
|
||||
onClick={() => setShowOfflineSales(true)}
|
||||
/>
|
||||
</span>
|
||||
|
||||
{/* PHASE 6 — DateInput carries its own label, so the separate "Date:"
|
||||
caption is dropped rather than duplicated beside it. The comment sits
|
||||
@@ -486,12 +518,14 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
)}
|
||||
|
||||
{(selectedCategories.length > 0 || stockHealthFilter.length > 0 || search) && (
|
||||
<button
|
||||
onClick={() => { setSelectedCategories([]); setStockHealthFilter([]); setSearch(''); }}
|
||||
className="mt-2 w-full py-2 bg-slate-100 text-slate-600 rounded-xl text-[10px] font-bold uppercase tracking-wider hover:bg-slate-200 transition-colors cursor-pointer"
|
||||
>
|
||||
Clear All Filters
|
||||
</button>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
label="Clear all filters"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => { setSelectedCategories([]); setStockHealthFilter([]); setSearch(''); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -516,12 +550,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
</div>
|
||||
{/* Action Toolbar */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
<Button
|
||||
label="Filter"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Layers size={14} />}
|
||||
onClick={() => setIsLocalSidebarOpen(!isLocalSidebarOpen)}
|
||||
className="flex items-center gap-2 bg-white border border-slate-200 px-3 py-2 rounded-xl text-xs font-bold hover:bg-slate-50 transition-colors text-slate-700 shadow-sm cursor-pointer h-[36px]"
|
||||
>
|
||||
<Layers size={14} /> Filter
|
||||
</button>
|
||||
/>
|
||||
|
||||
{/* `hasClear` replaces the absolutely-positioned X button, and
|
||||
`startIcon` replaces the absolutely-positioned magnifier — so
|
||||
@@ -600,25 +635,39 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />}
|
||||
{picks[p.id].status === 'Pending' ? 'Requested' : picks[p.id].status} ({picks[p.id].qty})
|
||||
</span>
|
||||
{/* Both carried only a `title` and, for the second,
|
||||
a bare X — so a card grid announced "Edit" and
|
||||
nothing, N times, with no product name. */}
|
||||
<div className="flex gap-1">
|
||||
{picks[p.id].status === 'Pending' && (
|
||||
<button onClick={(e) => { e.stopPropagation(); setTempQty(picks[p.id].qty); setActiveQtyProduct(p); }} title="Edit Quantity" className="px-2 py-0.5 text-amber-600 hover:bg-amber-100 bg-white border border-amber-200 transition-colors shadow-sm text-[9px] font-bold">Edit</button>
|
||||
<Button
|
||||
label="Edit"
|
||||
aria-label={`Edit requested quantity for ${p.name}`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setTempQty(picks[p.id].qty); setActiveQtyProduct(p); }}
|
||||
/>
|
||||
)}
|
||||
{picks[p.id].status !== 'Approved' && (
|
||||
<button onClick={(e) => { e.stopPropagation(); togglePick(p.id); }} title="Remove" className={`px-1.5 py-0.5 bg-white border transition-colors shadow-sm ${
|
||||
picks[p.id].status === 'Rejected' ? 'text-rose-500 border-rose-200' :
|
||||
'text-amber-500 border-amber-200'
|
||||
}`}><X size={12} /></button>
|
||||
<IconButton
|
||||
label={`Remove ${p.name} from your request`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<X size={12} />}
|
||||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); togglePick(p.id); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setTempQty(1); setActiveQtyProduct(p); }}
|
||||
className="w-full bg-[#f8fafc] hover:bg-[#662582] text-slate-600 hover:text-white border border-slate-200 hover:border-[#662582] px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Plus size={12} /> Add to Store
|
||||
</button>
|
||||
<Button
|
||||
label="Add to Store"
|
||||
aria-label={`Add ${p.name} to your store`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Plus size={12} />}
|
||||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setTempQty(1); setActiveQtyProduct(p); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -639,105 +688,93 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
sub="Browse the catalogue and add items to your store to request stock."
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] overflow-hidden">
|
||||
<ShellCard className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse" style={{ minWidth: 800 }}>
|
||||
<thead>
|
||||
<tr className="bg-slate-50/80 border-b border-slate-200">
|
||||
<Table style={{ minWidth: 800 }}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{['Requested At', 'Store', 'Product', 'Qty', 'Status', 'Resolved At', ''].map((h, i) => (
|
||||
<th key={i} className="px-5 py-4 text-[10px] font-black uppercase tracking-widest text-slate-500 whitespace-nowrap">{h}</th>
|
||||
<TableHeaderCell key={i}>{h}</TableHeaderCell>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(stockRequestsQ.data || []).map((data: any) => {
|
||||
const pid = String(data.productid);
|
||||
const prod = products.find(p => String(p.id) === pid);
|
||||
const isApproved = data.status === 'Approved';
|
||||
const isRejected = data.status === 'Rejected';
|
||||
const isCancelled = data.status === 'Cancelled';
|
||||
const isReceived = data.status === 'Received';
|
||||
|
||||
// Same mapping as the admin side of this flow: 'Received'
|
||||
// was indigo and 'Cancelled' slate, neither of which has a
|
||||
// Badge equivalent, so they take the nearest semantic
|
||||
// meaning rather than an invented hue.
|
||||
const statusVariant =
|
||||
data.status === 'Received' ? 'info' :
|
||||
data.status === 'Approved' ? 'success' :
|
||||
data.status === 'Rejected' ? 'error' :
|
||||
data.status === 'Cancelled' ? 'neutral' :
|
||||
'warning';
|
||||
|
||||
return (
|
||||
<tr key={data.requestid} className="transition-all duration-200 hover:bg-slate-50/80 group">
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-slate-900">
|
||||
<TableRow key={data.requestid} className="group">
|
||||
<TableCell>
|
||||
<div className="flex flex-col whitespace-nowrap">
|
||||
<span className="text-sm font-semibold">
|
||||
{data.created ? new Date(data.created).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
<span className="text-xs font-medium opacity-60">
|
||||
{data.created ? new Date(data.created).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-indigo-50/50 text-indigo-700 border border-indigo-100/50">
|
||||
<Store size={14} className="text-indigo-500" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="inline-flex items-center gap-2 whitespace-nowrap">
|
||||
<Store size={14} className="text-indigo-500" />
|
||||
<span className="font-bold text-xs">{storeName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<>
|
||||
<div className="relative w-10 h-10 rounded-xl overflow-hidden border border-slate-200 bg-white shadow-sm shrink-0 group-hover:shadow transition-shadow">
|
||||
<img src={data.productimage || prod?.image || PLACEHOLDER} alt={data.productname || prod?.name || 'Product'} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-sm text-slate-900 truncate group-hover:text-indigo-700 transition-colors">{data.productname || prod?.name}</p>
|
||||
<p className="text-xs font-medium text-slate-500 truncate mt-0.5">{prod?.sku || `SKU-${data.productid}`}</p>
|
||||
</div>
|
||||
</>
|
||||
<div className="relative w-10 h-10 rounded-xl overflow-hidden shrink-0">
|
||||
<img src={data.productimage || prod?.image || PLACEHOLDER} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-sm truncate">{data.productname || prod?.name}</p>
|
||||
<p className="text-xs font-medium truncate mt-0.5 opacity-60">{prod?.sku || `SKU-${data.productid}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 rounded-md bg-slate-100 text-slate-700 font-bold text-sm border border-slate-200/60">
|
||||
{data.qty || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${
|
||||
isReceived ? 'bg-indigo-50 text-indigo-700 border-indigo-200' :
|
||||
isApproved ? 'bg-emerald-50 text-emerald-700 border-emerald-200' :
|
||||
isRejected ? 'bg-rose-50 text-rose-700 border-rose-200' :
|
||||
isCancelled ? 'bg-slate-50 text-slate-700 border-slate-200' :
|
||||
'bg-amber-50 text-amber-700 border-amber-200'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${
|
||||
isReceived ? 'bg-indigo-500' :
|
||||
isApproved ? 'bg-emerald-500' :
|
||||
isRejected ? 'bg-rose-500' :
|
||||
isCancelled ? 'bg-slate-400' :
|
||||
'bg-amber-500 animate-pulse'
|
||||
}`} />
|
||||
{data.status || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 whitespace-nowrap">
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-bold text-sm tabular-nums">{data.qty || '—'}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant} label={data.status || '—'} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs font-medium whitespace-nowrap opacity-60">
|
||||
{data.updated ? new Date(data.updated).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 whitespace-nowrap text-right">
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isApproved && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkReceived(data);
|
||||
}}
|
||||
disabled={updateRequestMutation.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold text-white bg-indigo-600 hover:bg-indigo-700 transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<CheckCircle2 size={14} /> Mark as Received
|
||||
</button>
|
||||
<div className="text-right">
|
||||
<Button
|
||||
label="Mark as Received"
|
||||
aria-label={`Mark ${data.productname || prod?.name || 'product'} as received`}
|
||||
size="sm"
|
||||
icon={<CheckCircle2 size={14} />}
|
||||
isDisabled={updateRequestMutation.isPending}
|
||||
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleMarkReceived(data); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</ShellCard>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -756,9 +793,11 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
title="No stock matches your filters"
|
||||
sub="Try a different keyword or clear the sidebar filters to find an item in your store."
|
||||
action={
|
||||
<button onClick={() => { setSearch(''); setSelectedCategories([]); setStockHealthFilter([]); }} className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#662582] hover:bg-[#531e6a] transition shadow-sm cursor-pointer">
|
||||
<X size={13} /> Clear filters
|
||||
</button>
|
||||
<Button
|
||||
label="Clear filters"
|
||||
icon={<X size={13} />}
|
||||
onClick={() => { setSearch(''); setSelectedCategories([]); setStockHealthFilter([]); }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@@ -794,11 +833,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
<span>Stock: <strong className="font-extrabold font-mono text-slate-900">{(it.closing ?? 0).toLocaleString('en-IN')}</strong></span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full bg-[#f8fafc] group-hover:bg-[#662582] text-slate-600 group-hover:text-white border border-slate-200 group-hover:border-[#662582] px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
{/* Was a <button> with no onClick — a control that did
|
||||
nothing but sat in the tab order and told a screen
|
||||
reader it was actionable. It is a stock readout, so
|
||||
it reads as one. */}
|
||||
<div className="w-full px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-center" style={{ background: 'rgba(127,127,127,0.06)' }}>
|
||||
Stock Level: {(it.closing ?? 0).toLocaleString('en-IN')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -815,7 +856,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
<div className="bg-[#0f172a] text-white rounded-2xl shadow-2xl border border-white/10 px-5 py-4 w-80 animate-in slide-in-from-bottom-4 duration-300">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-bold">{pickCount} product{pickCount > 1 ? 's' : ''} requested</span>
|
||||
<button onClick={() => { setPicks({}); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer transition-colors">Clear All</button>
|
||||
<Button label="Clear All" variant="ghost" size="sm" onClick={() => { setPicks({}); setNotice(false); }} />
|
||||
</div>
|
||||
<AwaitingApi label="Request submitted to Admin" api="stock-request API" compact className="bg-white/5 border-white/15 text-emerald-300" />
|
||||
</div>
|
||||
@@ -825,12 +866,8 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
|
||||
{/* QUANTITY SELECTION CENTERED MODAL */}
|
||||
{activeQtyProduct && (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-900/60 backdrop-blur-md animate-in fade-in duration-300"
|
||||
onClick={() => setActiveQtyProduct(null)}
|
||||
/>
|
||||
<div className="relative w-[340px] max-w-[95vw] bg-white rounded-2xl shadow-[0_24px_60px_rgba(0,0,0,0.15)] border border-white/50 p-5 animate-in zoom-in-[0.97] duration-300 flex flex-col gap-4">
|
||||
<CatalogueModal onClose={() => setActiveQtyProduct(null)} width={340}>
|
||||
<div className="p-5 flex flex-col gap-4">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center pb-2 border-b border-slate-100/80">
|
||||
@@ -838,12 +875,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
<h2 className="text-base font-bold text-slate-900 tracking-tight">Request Stock</h2>
|
||||
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">Bulk Order</p>
|
||||
</div>
|
||||
<button
|
||||
<IconButton
|
||||
label="Close request stock"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<X size={14} strokeWidth={2.5} />}
|
||||
onClick={() => setActiveQtyProduct(null)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-full bg-slate-50 hover:bg-slate-100 text-slate-400 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
<X size={14} strokeWidth={2.5} />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Product Card summary */}
|
||||
@@ -868,17 +906,14 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 block mb-2">Quick Select</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[10, 25, 50, 100].map(qty => (
|
||||
<button
|
||||
<Button
|
||||
key={qty}
|
||||
label={String(qty)}
|
||||
aria-label={`Request ${qty} units`}
|
||||
size="sm"
|
||||
variant={tempQty === qty ? 'primary' : 'secondary'}
|
||||
onClick={() => setTempQty(qty)}
|
||||
className={`relative overflow-hidden py-1.5 rounded-lg text-[11px] font-bold transition-all duration-300 ${
|
||||
tempQty === qty
|
||||
? 'bg-[#662582] text-white shadow-sm ring-1 ring-[#662582] ring-offset-1'
|
||||
: 'bg-white text-slate-600 border border-slate-200 hover:border-[#662582]/40 hover:bg-purple-50/50'
|
||||
}`}
|
||||
>
|
||||
{qty}
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -903,21 +938,20 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<Button
|
||||
label="Confirm Request"
|
||||
icon={<CheckCircle2 size={14} strokeWidth={2.5} />}
|
||||
onClick={() => {
|
||||
setPickQty(activeQtyProduct.id, tempQty);
|
||||
setActiveQtyProduct(null);
|
||||
setNotice(true);
|
||||
setTimeout(() => setNotice(false), 3000);
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl text-xs font-bold transition-all bg-gradient-to-r from-[#662582] to-purple-800 text-white shadow-[0_4px_12px_rgba(102,37,130,0.2)] hover:shadow-[0_6px_16px_rgba(102,37,130,0.3)] hover:-translate-y-0.5"
|
||||
>
|
||||
<CheckCircle2 size={14} strokeWidth={2.5} /> Confirm Request
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</CatalogueModal>
|
||||
)}
|
||||
|
||||
{/* Floating FMCG Details Panel */}
|
||||
@@ -997,16 +1031,15 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
<CheckCircle2 size={18} className="text-emerald-600" />
|
||||
<span className="text-sm font-semibold text-emerald-800">Approved <span className="font-bold">({pick.qty} units)</span> — confirm once it arrives</span>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
label="Mark as Received"
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
isDisabled={updateRequestMutation.isPending}
|
||||
onClick={() => {
|
||||
setSelectedProduct(null);
|
||||
handleMarkReceived({ productid: selectedProduct.id, qty: pick.qty, requestid: pick.requestid });
|
||||
}}
|
||||
disabled={updateRequestMutation.isPending}
|
||||
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl bg-indigo-600 text-white hover:bg-indigo-700 font-semibold text-sm transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<CheckCircle2 size={18} /> Mark as Received
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
) : isRejected ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -1014,9 +1047,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
<X size={18} className="text-rose-600" />
|
||||
<span className="text-sm font-semibold text-rose-800">Stock request was rejected</span>
|
||||
</div>
|
||||
<button onClick={() => { setSelectedProduct(null); togglePick(selectedProduct.id); }} className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl bg-white text-slate-700 hover:bg-slate-50 shadow-sm border border-slate-200 font-semibold text-sm transition-colors">
|
||||
Clear Request
|
||||
</button>
|
||||
<Button label="Clear Request" variant="secondary" onClick={() => { setSelectedProduct(null); togglePick(selectedProduct.id); }} />
|
||||
</div>
|
||||
) : isPending ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -1025,18 +1056,12 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
|
||||
<span className="text-sm font-semibold text-amber-800">Requested <span className="font-bold">({pick.qty} units)</span></span>
|
||||
</div>
|
||||
<button onClick={() => { setSelectedProduct(null); setTempQty(pick.qty); setActiveQtyProduct(selectedProduct); }} className="px-4 py-2 rounded-lg text-amber-700 bg-white hover:bg-amber-100 border border-amber-200 text-sm font-semibold transition-colors shadow-sm">
|
||||
Edit
|
||||
</button>
|
||||
<Button label="Edit" variant="secondary" size="sm" onClick={() => { setSelectedProduct(null); setTempQty(pick.qty); setActiveQtyProduct(selectedProduct); }} />
|
||||
</div>
|
||||
<button onClick={() => { setSelectedProduct(null); togglePick(selectedProduct.id); }} className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl bg-white text-rose-600 hover:bg-rose-50 border border-slate-200 font-semibold text-sm transition-colors">
|
||||
<X size={18} strokeWidth={2} /> Cancel Request
|
||||
</button>
|
||||
<Button label="Cancel Request" variant="destructive" icon={<X size={18} strokeWidth={2} />} onClick={() => { setSelectedProduct(null); togglePick(selectedProduct.id); }} />
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => { setSelectedProduct(null); setTempQty(1); setActiveQtyProduct(selectedProduct); }} className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-semibold transition-colors bg-[#662582] text-white hover:bg-[#531e6a]">
|
||||
<Plus size={18} strokeWidth={2} /> Request Stock
|
||||
</button>
|
||||
<Button label="Request Stock" icon={<Plus size={18} strokeWidth={2} />} onClick={() => { setSelectedProduct(null); setTempQty(1); setActiveQtyProduct(selectedProduct); }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,47 @@ import { useAppRoles } from '../services/queries';
|
||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
|
||||
import { useNotify } from './Toasts';
|
||||
import { Card as ShellCard } from './consoleUi';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { RadioList, RadioListItem } from '@astryxdesign/core/RadioList';
|
||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
import { Heading } from '@astryxdesign/core/Heading';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Spinner } from '@astryxdesign/core/Spinner';
|
||||
|
||||
/**
|
||||
* PHASE 6 — the shared modal shell for this panel.
|
||||
*
|
||||
* Both overlays here were the same hand-rolled `fixed inset-0` construction:
|
||||
* a blurred backdrop, a white card, an inline max-width, and a header X.
|
||||
* Neither closed on Escape and neither trapped focus, so tabbing out of the
|
||||
* create form walked straight into the directory behind it.
|
||||
*/
|
||||
function UsersModal({
|
||||
onClose,
|
||||
width,
|
||||
children,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
width: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => { if (!open) onClose(); }}
|
||||
width={width}
|
||||
maxHeight="90vh"
|
||||
purpose="info"
|
||||
>
|
||||
{children}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface UsersPanelProps {
|
||||
tenantId?: number;
|
||||
@@ -326,72 +367,66 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAddUserModal(true)}
|
||||
className="bg-purple-650 hover:bg-purple-755 text-white px-5 py-3 rounded-xl text-sm font-bold uppercase tracking-wider flex items-center justify-center gap-2 cursor-pointer shadow-sm active:scale-95 transition-all border-none"
|
||||
>
|
||||
<Plus size={16} /> Add Team Member
|
||||
</button>
|
||||
<Button label="Add Team Member" icon={<Plus size={16} />} onClick={() => setShowAddUserModal(true)} />
|
||||
</div>
|
||||
|
||||
{/* Search & Filter Utility Bar */}
|
||||
<div className="flex flex-col md:flex-row gap-6 items-stretch md:items-center justify-between mb-4">
|
||||
<div className="relative w-full md:max-w-sm shrink-0">
|
||||
<div className="relative flex w-full items-center border-b-2 border-slate-200 focus-within:border-purple-600 transition-colors duration-300">
|
||||
<span className="pl-1 flex items-center pointer-events-none text-slate-400">
|
||||
<Search size={16} strokeWidth={3} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search team members..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-3 pr-10 py-2.5 bg-transparent border-none text-sm font-semibold text-slate-800 placeholder-slate-400 focus:outline-none outline-none"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-700 active:scale-95 transition-all p-1.5 flex items-center justify-center"
|
||||
>
|
||||
<X size={14} strokeWidth={3} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Measured in Chrome: this collapsed to 8px after the swap. The old
|
||||
`<input>` carried its own intrinsic width; TextInput does not, so the
|
||||
scrollable filter row beside it claimed the whole line. A fixed width
|
||||
here plus `min-w-0` on that row is what actually holds it open —
|
||||
flex-basis alone did not. */}
|
||||
<div className="w-full md:w-96 shrink-0">
|
||||
<TextInput
|
||||
label="Search team members"
|
||||
isLabelHidden
|
||||
placeholder="Search team members..."
|
||||
value={search}
|
||||
onChange={(v) => setSearch(v)}
|
||||
startIcon={<Search size={16} />}
|
||||
hasClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role filter capsules */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1 md:pb-0 scrollbar-none select-none">
|
||||
<button
|
||||
onClick={() => setUserRoleFilter('ALL')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs uppercase tracking-wider font-extrabold transition-all duration-200 cursor-pointer border ${
|
||||
userRoleFilter === 'ALL'
|
||||
? 'bg-purple-600 text-white border-purple-600 shadow-sm'
|
||||
: 'bg-white text-slate-600 border-slate-200/80 hover:text-slate-800 hover:bg-slate-100'
|
||||
}`}
|
||||
{/* Role filter — a single-select, so a real one. As a row of buttons
|
||||
nothing announced which filter was active; SegmentedControl emits a
|
||||
radiogroup with arrow-key navigation. */}
|
||||
<div className="min-w-0 overflow-x-auto pb-1 md:pb-0 scrollbar-none select-none">
|
||||
<SegmentedControl
|
||||
label="Filter by role"
|
||||
size="sm"
|
||||
value={userRoleFilter}
|
||||
onChange={(v) => setUserRoleFilter(v)}
|
||||
>
|
||||
All Roles
|
||||
</button>
|
||||
{roleOptions.map((roleNameStr) => (
|
||||
<button
|
||||
key={roleNameStr}
|
||||
onClick={() => setUserRoleFilter(roleNameStr)}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs uppercase tracking-wider font-extrabold transition-all duration-200 cursor-pointer border whitespace-nowrap ${
|
||||
userRoleFilter === roleNameStr
|
||||
? 'bg-purple-600 text-white border-purple-600 shadow-sm'
|
||||
: 'bg-white text-slate-600 border-slate-200/80 hover:text-slate-800 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{roleNameStr}
|
||||
</button>
|
||||
))}
|
||||
<SegmentedControlItem value="ALL" label="All Roles" />
|
||||
{roleOptions.map((roleNameStr) => (
|
||||
<SegmentedControlItem key={roleNameStr} value={roleNameStr} label={roleNameStr} />
|
||||
))}
|
||||
</SegmentedControl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Directory Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="col-span-full bg-white border border-slate-200/60 rounded-2xl p-12 text-center text-slate-500 font-bold text-base">
|
||||
{usersQ.isLoading ? 'Loading team list…' : 'No matches found in team list.'}
|
||||
<div className="col-span-full">
|
||||
<ShellCard className="p-12">
|
||||
{usersQ.isLoading ? (
|
||||
<div className="flex justify-center"><Spinner label="Loading team list…" /></div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Users size={26} />}
|
||||
title="No matches found in team list"
|
||||
description={search || userRoleFilter !== 'ALL' ? 'Try a different search or role filter.' : 'Add your first team member to get started.'}
|
||||
actions={
|
||||
search || userRoleFilter !== 'ALL'
|
||||
? <Button label="Clear filters" variant="secondary" onClick={() => { setSearch(''); setUserRoleFilter('ALL'); }} />
|
||||
: <Button label="Add Team Member" icon={<Plus size={14} />} onClick={() => setShowAddUserModal(true)} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ShellCard>
|
||||
</div>
|
||||
) : (
|
||||
filteredUsers.map((u) => {
|
||||
@@ -453,17 +488,17 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
{roleInfo.label}
|
||||
</span>
|
||||
{u.roleid !== 1 && (
|
||||
<button
|
||||
// The label alone read "Assign"/"Unassign" on every card,
|
||||
// so a screen reader heard the same two words N times with
|
||||
// no idea whose account they acted on.
|
||||
<Button
|
||||
label={u.roleid <= 0 ? 'Assign' : 'Unassign'}
|
||||
aria-label={`${u.roleid <= 0 ? 'Assign' : 'Unassign'} ${u.name}`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isDisabled={updateUserMut.isPending}
|
||||
onClick={() => handleToggleAssign(u.userid, u.roleid)}
|
||||
disabled={updateUserMut.isPending}
|
||||
className={`px-2 py-0.5 rounded-[8px] text-[9px] font-extrabold uppercase transition-colors cursor-pointer border ${
|
||||
u.roleid <= 0
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100'
|
||||
: 'bg-slate-50 text-slate-500 border-slate-200 hover:bg-slate-100 hover:text-slate-700'
|
||||
} ${updateUserMut.isPending ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{u.roleid <= 0 ? 'Assign' : 'Unassign'}
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -477,26 +512,26 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
|
||||
{/* CREATE NEW USER MODAL */}
|
||||
{showAddUserModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md select-none"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowAddUserModal(false); }}
|
||||
>
|
||||
<div className="bg-white border border-slate-200/80 w-full max-w-[30rem] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-sm font-sans cursor-default">
|
||||
|
||||
<UsersModal onClose={() => setShowAddUserModal(false)} width={480}>
|
||||
<div className="flex flex-col max-h-[90vh] text-sm font-sans">
|
||||
|
||||
{/* Modal Header */}
|
||||
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center shrink-0">
|
||||
<h4 className="font-bold text-slate-900 flex items-center gap-2.5 text-base">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-50 text-purple-650 flex items-center justify-center">
|
||||
<Users size={16} />
|
||||
</div>
|
||||
Add Team Member
|
||||
</h4>
|
||||
<button
|
||||
<div className="p-5 flex justify-between items-center shrink-0">
|
||||
<Heading level={3}>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-50 text-purple-650 flex items-center justify-center">
|
||||
<Users size={16} />
|
||||
</div>
|
||||
Add Team Member
|
||||
</span>
|
||||
</Heading>
|
||||
<IconButton
|
||||
label="Close add team member"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<X size={18} />}
|
||||
onClick={() => setShowAddUserModal(false)}
|
||||
className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 cursor-pointer transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modal Form */}
|
||||
@@ -507,147 +542,93 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Firstname */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">FIRSTNAME (*)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Harini"
|
||||
value={newUser.firstname}
|
||||
onChange={(e) => setNewUser({ ...newUser, firstname: e.target.value })}
|
||||
className="w-full border border-slate-200 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<TextInput
|
||||
label="First name" isRequired
|
||||
placeholder="e.g. Harini"
|
||||
value={newUser.firstname}
|
||||
onChange={(v) => setNewUser({ ...newUser, firstname: v })}
|
||||
/>
|
||||
|
||||
{/* Contactno */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">CONTACTNO (*)</label>
|
||||
<div className="relative">
|
||||
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||
<Phone size={14} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={10}
|
||||
placeholder="e.g. 9988776655"
|
||||
value={newUser.contactno}
|
||||
onChange={(e) => setNewUser({ ...newUser, contactno: e.target.value.replace(/\D/g, '') })}
|
||||
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextInput
|
||||
label="Contact number" isRequired
|
||||
placeholder="e.g. 9988776655"
|
||||
value={newUser.contactno}
|
||||
onChange={(v) => setNewUser({ ...newUser, contactno: v.replace(/\D/g, '').slice(0, 10) })}
|
||||
startIcon={<Phone size={14} />}
|
||||
/>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">EMAIL (*)</label>
|
||||
<div className="relative">
|
||||
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||
<Mail size={14} />
|
||||
</span>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="e.g. harini@store.com"
|
||||
value={newUser.email}
|
||||
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
|
||||
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextInput
|
||||
label="Email" type="email" isRequired
|
||||
placeholder="e.g. harini@store.com"
|
||||
value={newUser.email}
|
||||
onChange={(v) => setNewUser({ ...newUser, email: v })}
|
||||
startIcon={<Mail size={14} />}
|
||||
/>
|
||||
|
||||
{/* Interactive Role Buttons instead of standard select */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">SELECT ACCOUNT ROLE (*)</label>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{roleChoices.map((r) => {
|
||||
const isSelected = newUser.roleid === r.id;
|
||||
const Icon = r.icon;
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => setNewUser({ ...newUser, roleid: r.id })}
|
||||
className={`p-3 rounded-xl border text-left transition-all cursor-pointer flex gap-2.5 items-start ${
|
||||
isSelected
|
||||
? 'bg-purple-50 border-purple-500 ring-2 ring-purple-500/10'
|
||||
: 'bg-slate-50/40 hover:bg-slate-50 border-slate-200/80'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} className={`shrink-0 mt-0.5 ${isSelected ? 'text-purple-650' : 'text-slate-450'}`} />
|
||||
<div className="min-w-0">
|
||||
<span className={`font-bold text-xs block leading-tight ${isSelected ? 'text-purple-950' : 'text-slate-800'}`}>{r.label}</span>
|
||||
<span className="text-[10px] text-slate-455 leading-tight block mt-1 font-medium">{r.desc}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* RadioList, not the old two-column grid of buttons. This is
|
||||
a single-select and each choice carries a description, which
|
||||
RadioListItem takes directly. As buttons the selection lived
|
||||
only in border colour and announced nothing. The per-role
|
||||
icons are dropped — RadioListItem has no icon slot, and real
|
||||
radio semantics are worth more here than the glyph. */}
|
||||
<RadioList
|
||||
label="Select account role"
|
||||
isRequired
|
||||
value={String(newUser.roleid)}
|
||||
onChange={(v) => setNewUser({ ...newUser, roleid: Number(v) })}
|
||||
>
|
||||
{roleChoices.map((r) => (
|
||||
<RadioListItem key={r.id} value={String(r.id)} label={r.label} description={r.desc} />
|
||||
))}
|
||||
</RadioList>
|
||||
|
||||
{/* Store / branch — binds the user to a specific outlet (tenantid + locationid + applocationid) */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">STORE / BRANCH (*)</label>
|
||||
<div className="relative">
|
||||
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||
<Store size={14} />
|
||||
</span>
|
||||
<select
|
||||
value={newUser.locationid}
|
||||
onChange={(e) => {
|
||||
const lid = Number(e.target.value);
|
||||
const s = storeOptions.find((o) => o.locationid === lid);
|
||||
setNewUser({ ...newUser, locationid: lid, applocationid: s?.applocationid ?? 0 });
|
||||
}}
|
||||
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm cursor-pointer"
|
||||
>
|
||||
<option value={0}>{locationsQ.isLoading ? 'Loading stores…' : storeOptions.length ? 'Select a store…' : 'No stores found'}</option>
|
||||
{storeOptions.map((s) => (
|
||||
<option key={s.locationid} value={s.locationid}>{s.name}{s.address ? ` — ${s.address}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Selector
|
||||
label="Store / branch" isRequired
|
||||
value={newUser.locationid ? String(newUser.locationid) : ''}
|
||||
onChange={(v) => {
|
||||
const lid = Number(v) || 0;
|
||||
const s = storeOptions.find((o) => o.locationid === lid);
|
||||
setNewUser({ ...newUser, locationid: lid, applocationid: s?.applocationid ?? 0 });
|
||||
}}
|
||||
isLoading={locationsQ.isLoading}
|
||||
placeholder={locationsQ.isLoading ? 'Loading stores…' : storeOptions.length ? 'Select a store…' : 'No stores found'}
|
||||
startIcon={<Store size={14} />}
|
||||
options={storeOptions.map((s) => ({
|
||||
value: String(s.locationid),
|
||||
label: `${s.name}${s.address ? ` — ${s.address}` : ''}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{/* Rider shift — only when a rider role is selected */}
|
||||
{isRiderRole && (
|
||||
<div className="space-y-1.5 animate-in slide-in-from-top duration-250">
|
||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">RIDER SHIFT</label>
|
||||
<div className="relative">
|
||||
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||
<Clock size={14} />
|
||||
</span>
|
||||
<select
|
||||
value={newUser.shiftid}
|
||||
onChange={(e) => setNewUser({ ...newUser, shiftid: Number(e.target.value) })}
|
||||
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm cursor-pointer"
|
||||
>
|
||||
<option value={0}>{shiftsQ.isLoading ? 'Loading shifts…' : shiftOptions.length ? 'Select rider shift…' : 'No shifts available'}</option>
|
||||
{shiftOptions.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="animate-in slide-in-from-top duration-250">
|
||||
<Selector
|
||||
label="Rider shift"
|
||||
value={newUser.shiftid ? String(newUser.shiftid) : ''}
|
||||
onChange={(v) => setNewUser({ ...newUser, shiftid: Number(v) || 0 })}
|
||||
isLoading={shiftsQ.isLoading}
|
||||
placeholder={shiftsQ.isLoading ? 'Loading shifts…' : shiftOptions.length ? 'Select rider shift…' : 'No shifts available'}
|
||||
startIcon={<Clock size={14} />}
|
||||
options={shiftOptions.map((s) => ({ value: String(s.id), label: s.label }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Address — keyless autocomplete that fills suburb / city / state / postcode */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">ADDRESS</label>
|
||||
<label className="font-bold uppercase tracking-widest text-[10px] opacity-60">Address</label>
|
||||
<AddressAutocomplete value={newUser.address} onSelect={handleAddressSelect} placeholder="Search address…" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-sm">
|
||||
{(['suburb', 'city', 'state', 'postcode'] as const).map((key) => (
|
||||
<div className="space-y-1.5" key={key}>
|
||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">{key.toUpperCase()}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUser[key]}
|
||||
onChange={(e) => setNewUser({ ...newUser, [key]: e.target.value })}
|
||||
className="w-full border border-slate-200 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<TextInput
|
||||
key={key}
|
||||
label={key.charAt(0).toUpperCase() + key.slice(1)}
|
||||
value={newUser[key]}
|
||||
onChange={(v) => setNewUser({ ...newUser, [key]: v })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -655,57 +636,41 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="p-5 border-t border-slate-100 flex justify-end gap-sm bg-slate-50/50 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddUserModal(false)}
|
||||
className="px-5 py-2.5 border border-slate-200 hover:bg-slate-100 rounded-xl font-bold text-slate-500 hover:text-slate-700 cursor-pointer active:scale-95 transition-all text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
<div className="p-5 flex justify-end gap-sm shrink-0">
|
||||
<Button type="button" label="Cancel" variant="secondary" onClick={() => setShowAddUserModal(false)} />
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createUserMut.isPending}
|
||||
className="px-6 py-2.5 bg-purple-650 hover:bg-purple-755 text-white rounded-xl font-bold cursor-pointer shadow-sm disabled:opacity-60 disabled:cursor-not-allowed active:scale-95 transition-all flex items-center gap-1.5 border-none text-sm"
|
||||
>
|
||||
{createUserMut.isPending ? (
|
||||
<>
|
||||
<span className="w-2 h-2 rounded-full bg-white animate-pulse" />
|
||||
Creating…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check size={14} />
|
||||
Add Member
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
label={createUserMut.isPending ? 'Creating…' : 'Add Member'}
|
||||
icon={<Check size={14} />}
|
||||
isLoading={createUserMut.isPending}
|
||||
isDisabled={createUserMut.isPending}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</UsersModal>
|
||||
)}
|
||||
|
||||
{/* ASSIGN USER MODAL */}
|
||||
{assignUserId !== null && (
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md select-none"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setAssignUserId(null); }}
|
||||
>
|
||||
<div className="bg-white border border-slate-200/80 w-full max-w-[28rem] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-sm font-sans cursor-default">
|
||||
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center shrink-0">
|
||||
<h4 className="font-bold text-slate-900 flex items-center gap-2.5 text-base">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-50 text-emerald-650 flex items-center justify-center">
|
||||
<UserCheck size={16} />
|
||||
</div>
|
||||
Assign Team Member
|
||||
</h4>
|
||||
<button
|
||||
<UsersModal onClose={() => setAssignUserId(null)} width={448}>
|
||||
<div className="flex flex-col text-sm font-sans">
|
||||
<div className="p-5 flex justify-between items-center shrink-0">
|
||||
<Heading level={3}>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-50 text-emerald-650 flex items-center justify-center">
|
||||
<UserCheck size={16} />
|
||||
</div>
|
||||
Assign Team Member
|
||||
</span>
|
||||
</Heading>
|
||||
<IconButton
|
||||
label="Close assign team member"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<X size={18} />}
|
||||
onClick={() => setAssignUserId(null)}
|
||||
className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 cursor-pointer transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={async (e) => {
|
||||
@@ -723,60 +688,38 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
notifyFail(err, 'Could not assign user.');
|
||||
}
|
||||
}} className="p-6 space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">ROLE (*)</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={assignRoleId}
|
||||
onChange={(e) => setAssignRoleId(Number(e.target.value))}
|
||||
className="w-full border border-slate-200 rounded-xl px-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm cursor-pointer"
|
||||
>
|
||||
{roleChoices.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Selector
|
||||
label="Role" isRequired
|
||||
value={String(assignRoleId)}
|
||||
onChange={(v) => setAssignRoleId(Number(v) || assignRoleId)}
|
||||
options={roleChoices.map((r) => ({ value: String(r.id), label: r.label }))}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">STORE / LOCATION (*)</label>
|
||||
<div className="relative">
|
||||
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||
<Store size={14} />
|
||||
</span>
|
||||
<select
|
||||
value={assignLocationId}
|
||||
onChange={(e) => setAssignLocationId(Number(e.target.value))}
|
||||
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm cursor-pointer"
|
||||
required
|
||||
>
|
||||
<option value={0} disabled>{locationsQ.isLoading ? 'Loading stores…' : 'Select a store…'}</option>
|
||||
{storeOptions.map((s) => (
|
||||
<option key={s.locationid} value={s.locationid}>{s.name}{s.address ? ` — ${s.address}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Selector
|
||||
label="Store / location" isRequired
|
||||
value={assignLocationId ? String(assignLocationId) : ''}
|
||||
onChange={(v) => setAssignLocationId(Number(v) || 0)}
|
||||
isLoading={locationsQ.isLoading}
|
||||
placeholder={locationsQ.isLoading ? 'Loading stores…' : 'Select a store…'}
|
||||
startIcon={<Store size={14} />}
|
||||
options={storeOptions.map((s) => ({
|
||||
value: String(s.locationid),
|
||||
label: `${s.name}${s.address ? ` — ${s.address}` : ''}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<div className="pt-4 border-t border-slate-100 flex justify-end gap-sm mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAssignUserId(null)}
|
||||
className="px-5 py-2.5 border border-slate-200 hover:bg-slate-100 rounded-xl font-bold text-slate-500 cursor-pointer active:scale-95 transition-all text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
<div className="pt-4 flex justify-end gap-sm mt-6">
|
||||
<Button type="button" label="Cancel" variant="secondary" onClick={() => setAssignUserId(null)} />
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateUserMut.isPending || !assignLocationId}
|
||||
className="px-6 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold cursor-pointer shadow-sm disabled:opacity-60 disabled:cursor-not-allowed active:scale-95 transition-all flex items-center gap-1.5 border-none text-sm"
|
||||
>
|
||||
{updateUserMut.isPending ? 'Assigning…' : 'Assign User'}
|
||||
</button>
|
||||
label={updateUserMut.isPending ? 'Assigning…' : 'Assign User'}
|
||||
isLoading={updateUserMut.isPending}
|
||||
isDisabled={updateUserMut.isPending || !assignLocationId}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</UsersModal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query';
|
||||
import { useNotify } from '../components/Toasts';
|
||||
import type { Row } from './fiestaApi';
|
||||
import { loginRequest, matchTenantUser, buildAuthUser, type AuthUser } from './auth';
|
||||
import {
|
||||
@@ -1021,6 +1022,10 @@ export function useFiestaGlobalProducts(opts: {
|
||||
// ── Store Catalogue (Product Locations) ─────────────────────────────────────
|
||||
export function useFiestaCreateProductLocation() {
|
||||
const qc = useQueryClient();
|
||||
// PHASE 6 — the last alert() in the app. It lived here rather than in a
|
||||
// component, which is why the earlier sweep of 42 missed it. This is already
|
||||
// a hook, so useNotify() is legal in it.
|
||||
const { notifyFail } = useNotify();
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateProductLocationInput) => createProductLocation(input),
|
||||
onSuccess: (_, variables) => {
|
||||
@@ -1030,7 +1035,7 @@ export function useFiestaCreateProductLocation() {
|
||||
qc.invalidateQueries({ queryKey: ['fiesta', 'stockStatement'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(`Error adding product: ${error.message}`);
|
||||
notifyFail(error, 'Could not add this product.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user