conversion astryx
This commit is contained in:
942
package-lock.json
generated
942
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,10 @@
|
|||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@astryxdesign/core": "0.3.0",
|
||||||
|
"@astryxdesign/theme-neutral": "0.3.0",
|
||||||
"@google/genai": "^2.4.0",
|
"@google/genai": "^2.4.0",
|
||||||
|
"@stylexjs/stylex": "^0.19.0",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
@@ -32,8 +35,11 @@
|
|||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@astryxdesign/cli": "^0.3.0",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.4",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
|
|||||||
11
src/App.tsx
11
src/App.tsx
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import PosStaffPanelAstryx from './components/spike/PosStaffPanelAstryx'; // PHASE 1 SPIKE — throwaway
|
||||||
|
import ThemeShowcase from './components/spike/ThemeShowcase'; // PHASE 2 gate — throwaway
|
||||||
|
import ConsoleUiProbe from './components/spike/ConsoleUiProbe'; // PHASE 3 verification — throwaway
|
||||||
import {
|
import {
|
||||||
Network,
|
Network,
|
||||||
Truck,
|
Truck,
|
||||||
@@ -560,6 +563,14 @@ export default function App() {
|
|||||||
if (authRole === null) {
|
if (authRole === null) {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* PHASE 1 SPIKE — throwaway, delete with the spike folder. Sits above
|
||||||
|
the auth gate only so the probe is reachable without a login. */}
|
||||||
|
<Route path="/spike/astryx" element={<PosStaffPanelAstryx />} />
|
||||||
|
{/* PHASE 2 gate — the stock Astryx showcase rendered through the Nearle
|
||||||
|
theme. This is the acceptance test for the palette: every component
|
||||||
|
on one page, in whichever mode the OS is set to. */}
|
||||||
|
<Route path="/spike/theme" element={<ThemeShowcase />} />
|
||||||
|
<Route path="/spike/consoleui" element={<ConsoleUiProbe />} />
|
||||||
<Route path="/login" element={<LoginView onLogin={handleLogin} />} />
|
<Route path="/login" element={<LoginView onLogin={handleLogin} />} />
|
||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -33,8 +33,13 @@ import {
|
|||||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, str as fstr, num as fnum } from '../services/fiestaApi';
|
||||||
import StoreQRView from './StoreQRView';
|
import StoreQRView from './StoreQRView';
|
||||||
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
|
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
|
||||||
|
import { useNotify } from './Toasts';
|
||||||
|
|
||||||
export default function AdminConsole({ activeTab: propActiveTab, showHeader = true, onBack, tenantId }: { activeTab?: 'tenant' | 'store' | 'rider', showHeader?: boolean, onBack?: () => void, tenantId?: number }) {
|
export default function AdminConsole({ activeTab: propActiveTab, showHeader = true, onBack, tenantId }: { activeTab?: 'tenant' | 'store' | 'rider', showHeader?: boolean, onBack?: () => void, tenantId?: number }) {
|
||||||
|
// 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();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'tenant' | 'store' | 'rider'>(propActiveTab || 'tenant');
|
const [activeTab, setActiveTab] = useState<'tenant' | 'store' | 'rider'>(propActiveTab || 'tenant');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -171,7 +176,7 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
|
|||||||
const handleTenantSubmit = async (e: React.FormEvent) => {
|
const handleTenantSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!tenantForm.tenantname || !tenantForm.companyname || !tenantForm.primarycontact || !tenantForm.primaryemail || !tenantForm.locationname) {
|
if (!tenantForm.tenantname || !tenantForm.companyname || !tenantForm.primarycontact || !tenantForm.primaryemail || !tenantForm.locationname) {
|
||||||
alert('Kindly fill in all required fields.');
|
notifyFail('Kindly fill in all required fields.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -197,16 +202,16 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
setTenantSuccess(res);
|
setTenantSuccess(res);
|
||||||
alert('Tenant Onboarded successfully!');
|
notifyOk('Tenant onboarded.');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to onboard tenant.');
|
notifyFail(err, 'Failed to onboard tenant.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStoreSubmit = async (e: React.FormEvent) => {
|
const handleStoreSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!storeForm.locationname) {
|
if (!storeForm.locationname) {
|
||||||
alert('Location Name is required.');
|
notifyFail('Location name is required.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -221,16 +226,16 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
|
|||||||
roleid: 4,
|
roleid: 4,
|
||||||
});
|
});
|
||||||
setStoreSuccess(res);
|
setStoreSuccess(res);
|
||||||
alert('Store Location created successfully!');
|
notifyOk('Store location created.');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to create store location.');
|
notifyFail(err, 'Failed to create store location.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRiderSubmit = async (e: React.FormEvent) => {
|
const handleRiderSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!riderForm.firstname || !riderForm.email || !riderForm.contactno || !riderForm.locationid) {
|
if (!riderForm.firstname || !riderForm.email || !riderForm.contactno || !riderForm.locationid) {
|
||||||
alert('Please fill out the Rider personal details and assign a store.');
|
notifyFail('Fill out the rider details and assign a store.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -282,9 +287,9 @@ VALUES (${newUserId}, 1, 'Active', NOW());
|
|||||||
userid: newUserId,
|
userid: newUserId,
|
||||||
});
|
});
|
||||||
|
|
||||||
alert('Rider User created in app_users! SQL Script generated below.');
|
notifyOk('Rider user created. SQL script generated below.');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to create rider user.');
|
notifyFail(err, 'Failed to create rider user.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -902,7 +907,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
|
|||||||
>
|
>
|
||||||
<option value={0}>Select Hub location...</option>
|
<option value={0}>Select Hub location...</option>
|
||||||
{locations.map((loc) => (
|
{locations.map((loc) => (
|
||||||
<option key={loc.locationid} value={Number(loc.locationid)}>{fstr(loc.locationname)}</option>
|
<option key={fstr(loc.locationid)} value={Number(loc.locationid)}>{fstr(loc.locationname)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1261,7 +1266,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
|
|||||||
{tenantsQ.isLoading ? (
|
{tenantsQ.isLoading ? (
|
||||||
<option>Loading tenants...</option>
|
<option>Loading tenants...</option>
|
||||||
) : (tenantsQ.data || []).map((t) => (
|
) : (tenantsQ.data || []).map((t) => (
|
||||||
<option key={t.tenantid} value={Number(t.tenantid)}>{fstr(t.tenantname)} (#{fstr(t.tenantid)})</option>
|
<option key={fstr(t.tenantid)} value={Number(t.tenantid)}>{fstr(t.tenantname)} (#{fstr(t.tenantid)})</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1574,7 +1579,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
|
|||||||
>
|
>
|
||||||
<option value={0}>Select Hub location...</option>
|
<option value={0}>Select Hub location...</option>
|
||||||
{locations.map((loc) => (
|
{locations.map((loc) => (
|
||||||
<option key={loc.locationid} value={Number(loc.locationid)}>{fstr(loc.locationname)}</option>
|
<option key={fstr(loc.locationid)} value={Number(loc.locationid)}>{fstr(loc.locationname)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { ShoppingCart, X, Package, Trash2, Plus, ArrowRight, Layers } from 'lucide-react';
|
import { ShoppingCart, X, Package, Trash2, Plus, ArrowRight, Layers } from 'lucide-react';
|
||||||
import { useCompare } from '../contexts/CompareContext';
|
import { useCompare } from '../contexts/CompareContext';
|
||||||
import { useStoreCatalogue } from '../services/storeCatalogue';
|
import { useStoreCatalogue } from '../services/storeCatalogue';
|
||||||
|
import { useNotify } from './Toasts';
|
||||||
|
|
||||||
interface BulkCartDrawerProps {
|
interface BulkCartDrawerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -12,13 +13,14 @@ interface BulkCartDrawerProps {
|
|||||||
export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: BulkCartDrawerProps) {
|
export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: BulkCartDrawerProps) {
|
||||||
const { selectedProducts, removeProduct, clearSelection, setIsComparing } = useCompare();
|
const { selectedProducts, removeProduct, clearSelection, setIsComparing } = useCompare();
|
||||||
const storeCat = useStoreCatalogue();
|
const storeCat = useStoreCatalogue();
|
||||||
|
const { notifyOk } = useNotify();
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const handleBulkAdd = () => {
|
const handleBulkAdd = () => {
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
selectedProducts.forEach(prod => {
|
selectedProducts.forEach(prod => {
|
||||||
if (prod.verified && !storeCat.has(prod.id)) {
|
if (prod.verified && !storeCat.has(String(prod.id))) {
|
||||||
storeCat.add({
|
storeCat.add({
|
||||||
productid: String(prod.id),
|
productid: String(prod.id),
|
||||||
name: prod.name,
|
name: prod.name,
|
||||||
@@ -43,7 +45,10 @@ export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: Bu
|
|||||||
addedCount++;
|
addedCount++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
alert(addedCount > 0 ? `Successfully added ${addedCount} product(s) to the store catalogue!` : 'Selected products are already in the catalogue or not verified.');
|
// PHASE 6 — "nothing was added" is not an error, it is a result, so it goes
|
||||||
|
// through notifyOk rather than being dressed up as a failure.
|
||||||
|
if (addedCount > 0) notifyOk(`Added ${addedCount} product${addedCount === 1 ? '' : 's'} to the store catalogue.`);
|
||||||
|
else notifyOk('Nothing to add — those products are already in the catalogue, or not verified.');
|
||||||
if (addedCount > 0) onClose();
|
if (addedCount > 0) onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,7 +60,8 @@ export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: Bu
|
|||||||
removedCount++;
|
removedCount++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
alert(removedCount > 0 ? `Successfully removed ${removedCount} product(s) from the store catalogue!` : 'None of the selected products were in the catalogue.');
|
if (removedCount > 0) notifyOk(`Removed ${removedCount} product${removedCount === 1 ? '' : 's'} from the store catalogue.`);
|
||||||
|
else notifyOk('Nothing to remove — none of those products were in the catalogue.');
|
||||||
if (removedCount > 0) onClose();
|
if (removedCount > 0) onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ import { useStoreCatalogue, usePriceEverywhere } from '../services/storeCatalogu
|
|||||||
import { useFiestaTenantLocations, useFiestaProductLocations } from '../services/fiestaQueries';
|
import { useFiestaTenantLocations, useFiestaProductLocations } from '../services/fiestaQueries';
|
||||||
import { num as fnum } from '../services/fiestaApi';
|
import { num as fnum } from '../services/fiestaApi';
|
||||||
import ImportProductModal from './ImportProductModal';
|
import ImportProductModal from './ImportProductModal';
|
||||||
|
import { useNotify } from './Toasts';
|
||||||
import { CardCarousel } from './CatalogueGallery';
|
import { CardCarousel } from './CatalogueGallery';
|
||||||
|
// PHASE 6 — the Global Catalogue's own controls. This component is rendered by
|
||||||
|
// BOTH the admin (via InventoryView) and the super admin, so a change here
|
||||||
|
// lands on two logins at once.
|
||||||
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
|
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
||||||
|
|
||||||
interface CatalogueBrowserProps {
|
interface CatalogueBrowserProps {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
@@ -87,6 +93,9 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
|
|||||||
[tenantCategories],
|
[tenantCategories],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// PHASE 6 — replaces alert(), which blocked the whole browser until dismissed.
|
||||||
|
const { notifyOk, notifyFail } = useNotify();
|
||||||
|
|
||||||
const handleImportSubmit = (item: ImportCatalogueProductRequest) => {
|
const handleImportSubmit = (item: ImportCatalogueProductRequest) => {
|
||||||
importProductMutation.mutate([item], {
|
importProductMutation.mutate([item], {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -101,10 +110,14 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
|
|||||||
//
|
//
|
||||||
// The product now lands in the Admin Catalogue alone. Pricing and
|
// The product now lands in the Admin Catalogue alone. Pricing and
|
||||||
// publishing happen there, in that order.
|
// publishing happen there, in that order.
|
||||||
|
//
|
||||||
|
// PHASE 6 — the toast says where it went. Since the import stopped
|
||||||
|
// publishing, a silent success looked identical to nothing happening:
|
||||||
|
// the product does not appear in any store, which is correct but
|
||||||
|
// indistinguishable from a failure.
|
||||||
|
notifyOk('Imported to your Admin Catalogue. Set a price there to publish it.');
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => notifyFail(err, 'Failed to import product.'),
|
||||||
alert(err.message || 'Failed to import product.');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,21 +144,14 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{brandsData.map((b) => (
|
{brandsData.map((b) => (
|
||||||
<label key={b.brand} className="flex items-center gap-3 p-2 rounded-none hover:bg-slate-50 cursor-pointer group transition-colors">
|
<div key={b.brand} className="flex items-center gap-2 px-1 py-0.5">
|
||||||
<div className="relative flex items-center justify-center shrink-0">
|
<CheckboxInput
|
||||||
<input
|
label={b.brand.replace("brand_", "").toUpperCase()}
|
||||||
type="checkbox"
|
value={brand === b.brand}
|
||||||
checked={brand === b.brand}
|
|
||||||
onChange={() => setBrand(brand === b.brand ? undefined : b.brand)}
|
onChange={() => setBrand(brand === b.brand ? undefined : b.brand)}
|
||||||
className="peer appearance-none w-5 h-5 border-2 border-slate-300 rounded-none 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} />
|
<span className="ml-auto text-[10px] font-bold opacity-60">{b.product_count}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs font-semibold select-none transition-colors truncate ${brand === b.brand ? 'text-[#662582]' : 'text-slate-600 group-hover:text-slate-900'}`}>
|
|
||||||
{b.brand.replace('brand_', '').toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto text-[10px] text-slate-400 font-bold">{b.product_count}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -198,23 +204,16 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-nowrap items-center gap-2 shrink-0">
|
<div className="flex flex-nowrap items-center gap-2 shrink-0">
|
||||||
<div className="relative bg-slate-50 border border-slate-200 rounded-none flex items-center h-[32px] px-2.5 shadow-sm min-w-[200px] hover:border-purple-300 transition-colors group focus-within:border-purple-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-purple-100">
|
<div className="w-64">
|
||||||
<Search size={14} className="text-slate-400 group-focus-within:text-purple-600 transition-colors shrink-0" />
|
<TextInput
|
||||||
<input
|
label="Search catalogue"
|
||||||
type="text"
|
isLabelHidden
|
||||||
placeholder="Search catalogue..."
|
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
onChange={(v) => setKeyword(v)}
|
||||||
className="bg-transparent border-none outline-none text-xs font-semibold text-slate-700 ml-2 w-full placeholder:text-slate-400 placeholder:font-medium"
|
placeholder="Search catalogue…"
|
||||||
|
startIcon={<Search size={14} />}
|
||||||
|
hasClear
|
||||||
/>
|
/>
|
||||||
{keyword && (
|
|
||||||
<button
|
|
||||||
onClick={() => setKeyword('')}
|
|
||||||
className="ml-1 text-slate-400 hover:text-slate-700 bg-slate-200 hover:bg-slate-300 rounded-full p-0.5 transition-colors"
|
|
||||||
>
|
|
||||||
<X size={10} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { AlertTriangle, X } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||||
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHASE 5 — rebuilt on Astryx Dialog.
|
||||||
|
*
|
||||||
|
* The props are untouched, so both call sites were left alone. What changed is
|
||||||
|
* everything underneath: this was a hand-rolled `fixed inset-0` overlay, and it
|
||||||
|
* is now a real `<dialog>`.
|
||||||
|
*
|
||||||
|
* What that buys, none of which the original had:
|
||||||
|
* • Escape closes it (verified — see the `purpose` note below, the first
|
||||||
|
* choice of 'required' silently disabled this)
|
||||||
|
* • focus is trapped inside while open, and returns to the opener on close
|
||||||
|
* • the rest of the page is inert to screen readers
|
||||||
|
* • width comes from the Dialog, so it can no longer be squeezed by the
|
||||||
|
* `max-w-*` collision that once rendered these as ~100px strips
|
||||||
|
*
|
||||||
|
* `isLoading` still guards dismissal: onOpenChange is ignored mid-flight so a
|
||||||
|
* stray Escape cannot close the box while a mutation is in the air.
|
||||||
|
*/
|
||||||
|
|
||||||
interface ConfirmModalProps {
|
interface ConfirmModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -24,67 +45,42 @@ export function ConfirmModal({
|
|||||||
isDestructive = false,
|
isDestructive = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: ConfirmModalProps) {
|
}: ConfirmModalProps) {
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Dialog
|
||||||
className="fixed inset-0 bg-[#0f172a]/40 backdrop-blur-sm z-[200] flex items-center justify-center p-4 animate-in fade-in duration-200"
|
isOpen={isOpen}
|
||||||
onClick={(e) => { if (e.target === e.currentTarget && !isLoading) onCancel(); }}
|
onOpenChange={(open) => {
|
||||||
|
// Ignore every close request while a mutation is running — the original
|
||||||
|
// did this by disabling the buttons and swallowing backdrop clicks.
|
||||||
|
if (!open && !isLoading) onCancel();
|
||||||
|
}}
|
||||||
|
width={400}
|
||||||
|
// 'info', not 'required'. Verified in the browser: 'required' blocks
|
||||||
|
// Escape and backdrop dismissal entirely, which would have been a
|
||||||
|
// regression — the original closed on a backdrop click. Cancelling a
|
||||||
|
// confirmation is the safe direction, so making it harder protects
|
||||||
|
// nothing. 'info' keeps backdrop-dismiss and adds Escape.
|
||||||
|
purpose="info"
|
||||||
>
|
>
|
||||||
<div
|
<DialogHeader
|
||||||
className="bg-white border border-[#e2e8f0] rounded-xl w-[90vw] sm:w-[400px] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200"
|
title={title}
|
||||||
onClick={(e) => e.stopPropagation()}
|
startContent={isDestructive ? <AlertTriangle size={18} className="text-red-500" /> : undefined}
|
||||||
>
|
onOpenChange={(open) => { if (!open && !isLoading) onCancel(); }}
|
||||||
<div className="p-4 sm:p-5 border-b border-[#e2e8f0] bg-slate-50/50 flex justify-between items-center shrink-0">
|
/>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isDestructive && <AlertTriangle size={18} className="text-red-500" />}
|
|
||||||
<h2 className="text-base font-bold text-slate-800 uppercase tracking-wide">{title}</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="text-slate-400 hover:text-slate-600 hover:bg-slate-100 p-1.5 rounded-lg transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-5 overflow-y-auto">
|
<div className="text-sm leading-relaxed py-2">{message}</div>
|
||||||
<div className="text-sm text-slate-600 leading-relaxed">
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-slate-50/80 border-t border-slate-100 flex justify-end gap-3 shrink-0">
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
<button
|
<Button label={cancelText} variant="secondary" isDisabled={isLoading} onClick={onCancel} />
|
||||||
type="button"
|
<Button
|
||||||
onClick={onCancel}
|
label={confirmText}
|
||||||
disabled={isLoading}
|
variant={isDestructive ? 'destructive' : 'primary'}
|
||||||
className="px-4 py-2 text-sm font-semibold text-slate-600 hover:text-slate-800 hover:bg-slate-200 bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
|
isDisabled={isLoading}
|
||||||
>
|
isLoading={isLoading}
|
||||||
{cancelText}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
disabled={isLoading}
|
/>
|
||||||
className={`px-4 py-2 text-sm font-semibold rounded-lg transition-colors flex items-center justify-center min-w-[90px] disabled:opacity-50 ${
|
|
||||||
isDestructive
|
|
||||||
? 'bg-red-600 hover:bg-red-700 text-white shadow-sm shadow-red-200'
|
|
||||||
: 'bg-purple-600 hover:bg-purple-700 text-white shadow-sm shadow-purple-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<svg className="animate-spin h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
confirmText
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default ConfirmModal;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ 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, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi';
|
||||||
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
||||||
import CatalogueDetailPanel from './CatalogueDetailPanel';
|
import CatalogueDetailPanel from './CatalogueDetailPanel';
|
||||||
|
import { useNotify } from './Toasts';
|
||||||
import CatalogueBrowser from './CatalogueBrowser';
|
import CatalogueBrowser from './CatalogueBrowser';
|
||||||
import OfflineSalesUpload from './OfflineSalesUpload';
|
import OfflineSalesUpload from './OfflineSalesUpload';
|
||||||
import { useCompare } from '../contexts/CompareContext';
|
import { useCompare } from '../contexts/CompareContext';
|
||||||
@@ -178,6 +179,9 @@ export default function InventoryView({
|
|||||||
}, [storeCat.items]);
|
}, [storeCat.items]);
|
||||||
|
|
||||||
const { priceEverywhere, isPending: isPricing } = usePriceEverywhere(tenantId);
|
const { priceEverywhere, isPending: isPricing } = usePriceEverywhere(tenantId);
|
||||||
|
// PHASE 6 — replaces alert(). A native alert blocks the whole browser and
|
||||||
|
// has to be dismissed before anything else can happen; a toast does not.
|
||||||
|
const { notifyOk, notifyFail } = useNotify();
|
||||||
const publishMut = usePublishProduct();
|
const publishMut = usePublishProduct();
|
||||||
const unpublishMut = useUnpublishProduct();
|
const unpublishMut = useUnpublishProduct();
|
||||||
const [importPrice, setImportPrice] = useState<string>('');
|
const [importPrice, setImportPrice] = useState<string>('');
|
||||||
@@ -734,9 +738,13 @@ export default function InventoryView({
|
|||||||
taxpercent: Number(cardImportTax) || 0,
|
taxpercent: Number(cardImportTax) || 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => setAddingPriceProdId(null),
|
onSuccess: () => {
|
||||||
onError: (err: any) =>
|
setAddingPriceProdId(null);
|
||||||
alert(err?.message || 'Could not publish this product.'),
|
// Publishing used to succeed in silence — the only
|
||||||
|
// sign it worked was a row changing somewhere below.
|
||||||
|
notifyOk(`${prod.name} is live in every outlet.`);
|
||||||
|
},
|
||||||
|
onError: (err: any) => notifyFail(err, 'Could not publish this product.'),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -1091,7 +1099,10 @@ export default function InventoryView({
|
|||||||
price: Number(importPrice),
|
price: Number(importPrice),
|
||||||
taxpercent: Number(drawerTax) || 0,
|
taxpercent: Number(drawerTax) || 0,
|
||||||
},
|
},
|
||||||
{ onError: (err: any) => alert(err?.message || 'Could not publish this product.') },
|
{
|
||||||
|
onSuccess: () => notifyOk(`${selectedAdminProduct.name} is live in every outlet.`),
|
||||||
|
onError: (err: any) => notifyFail(err, 'Could not publish this product.'),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
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"
|
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"
|
||||||
|
|||||||
98
src/components/NavRail.tsx
Normal file
98
src/components/NavRail.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHASE 4 — the one left rail.
|
||||||
|
*
|
||||||
|
* `SuperAdminSidebar` and `UserStoreSidebar` were 95% the same file. A diff of
|
||||||
|
* the two showed exactly three differences: the route prefix, the item corner
|
||||||
|
* radius, and the fact that the user rail closes itself after a tap on mobile
|
||||||
|
* while the super-admin rail does not. Everything else — the brand-purple rail,
|
||||||
|
* the collapse-to-icons behaviour, the backdrop, the active states — was
|
||||||
|
* duplicated line for line, which is why a fix to one never reached the other.
|
||||||
|
*
|
||||||
|
* Both are now thin wrappers over this. Each keeps its own props and its own
|
||||||
|
* appearance, so no page changed.
|
||||||
|
*
|
||||||
|
* NOT built on Astryx's `SideNav`. That would replace the main navigation of
|
||||||
|
* every role at once, and those screens sit behind a login I cannot reach to
|
||||||
|
* check the result. Deduplicating is safe and verifiable today; restyling the
|
||||||
|
* navigation is not. The swap belongs in a step where someone can look at it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { type LucideIcon } from 'lucide-react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
export interface NavRailItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavRailProps {
|
||||||
|
items: NavRailItem[];
|
||||||
|
/** Route prefix each item id is appended to, e.g. `/superadmin`. */
|
||||||
|
basePath: string;
|
||||||
|
/** Collapsed → icon-only rail; expanded → labels visible. */
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/**
|
||||||
|
* Item corner radius. The two rails disagreed, so it stays a prop rather than
|
||||||
|
* silently restyling one of them.
|
||||||
|
*/
|
||||||
|
itemRadius?: 'none' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NavRail({ items, basePath, isOpen, onClose, itemRadius = 'none' }: NavRailProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile Overlay */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="md:hidden fixed inset-0 bg-black/50 z-40 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside
|
||||||
|
className={`fixed left-0 top-0 h-screen bg-[#662582] border-r border-[#662582] text-white flex flex-col py-xl pt-16 z-40 transition-all duration-300 ${
|
||||||
|
isOpen ? 'translate-x-0 w-64' : '-translate-x-full md:translate-x-0 md:w-16'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<nav className="flex-1 space-y-1 overflow-y-auto px-xs">
|
||||||
|
{items.map((item) => {
|
||||||
|
const IconComponent = item.icon;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.id}
|
||||||
|
to={`${basePath}/${item.id}`}
|
||||||
|
title={item.label}
|
||||||
|
// Applied to both rails now. The super-admin one lacked it, so
|
||||||
|
// on a phone its menu stayed open over the page you had just
|
||||||
|
// navigated to — a bug by omission rather than by decision.
|
||||||
|
onClick={() => { if (window.innerWidth < 768) onClose(); }}
|
||||||
|
className={({ isActive }) => `w-full flex items-center py-3 ${itemRadius === 'lg' ? 'rounded-lg' : 'rounded-none'} text-left transition-all duration-200 cursor-pointer ${
|
||||||
|
isOpen ? 'gap-md px-md border-l-4' : 'justify-center px-0'
|
||||||
|
} ${
|
||||||
|
isActive
|
||||||
|
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-white' : '')
|
||||||
|
: 'text-purple-200 hover:bg-white/10 hover:text-white' + (isOpen ? ' border-transparent' : '')
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<IconComponent size={18} className={isActive ? 'text-white' : 'text-purple-300'} />
|
||||||
|
{isOpen && <span className="font-sans text-sm font-medium">{item.label}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr } from '../services/fiestaApi';
|
||||||
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
||||||
import AwaitingApi from './AwaitingApi';
|
import AwaitingApi from './AwaitingApi';
|
||||||
|
import { useNotify } from './Toasts';
|
||||||
|
|
||||||
interface OperationsViewProps {
|
interface OperationsViewProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -37,6 +38,10 @@ interface OperationsViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function OperationsView({ searchQuery, isCoimbatoreView }: OperationsViewProps) {
|
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
|
// Sub-tabs state
|
||||||
const [activeSubTab, setActiveSubTab] = useState<'inventory' | 'catalogue' | 'import'>('inventory');
|
const [activeSubTab, setActiveSubTab] = useState<'inventory' | 'catalogue' | 'import'>('inventory');
|
||||||
|
|
||||||
@@ -111,7 +116,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
|||||||
const handleAddSku = (e: React.FormEvent) => {
|
const handleAddSku = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newSku.sku || !newSku.name || !newSku.warehouse) {
|
if (!newSku.sku || !newSku.name || !newSku.warehouse) {
|
||||||
alert('Please fill out all metadata fields before committing.');
|
notifyFail('Fill out all metadata fields before committing.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +148,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
|||||||
const handleExecuteTransfer = (e: React.FormEvent) => {
|
const handleExecuteTransfer = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!transferData.sku || !transferData.origin || !transferData.destination) {
|
if (!transferData.sku || !transferData.origin || !transferData.destination) {
|
||||||
alert('Kindly configure transfer coordinates and SKU.');
|
notifyFail('Configure the transfer coordinates and SKU first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +167,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
alert(`Transfer of ${transferData.quantity} units committed successfully. Tracking ID: TRF-${Math.floor(Math.random() * 900000 + 100000)}`);
|
notifyOk(`Transfer of ${transferData.quantity} units committed successfully. Tracking ID: TRF-${Math.floor(Math.random() * 900000 + 100000)}`);
|
||||||
setShowTransferModal(false);
|
setShowTransferModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -414,7 +419,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const amount = prompt('Enter return item SKU code:');
|
const amount = prompt('Enter return item SKU code:');
|
||||||
if (amount) {
|
if (amount) {
|
||||||
alert(`Returns logged successfully for target SKU code ${amount}. Waiting for physical hub clearance inspection.`);
|
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"
|
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"
|
||||||
@@ -425,7 +430,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
alert('Generating automated physical audit compliance report draft sheets... Download started background.');
|
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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -473,7 +478,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
|||||||
verified: false
|
verified: false
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
alert(`${title} added successfully to unreleased draft portfolio.`);
|
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"
|
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"
|
||||||
@@ -549,7 +554,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const fileRef = prompt('Enter CSV filename representation path:');
|
const fileRef = prompt('Enter CSV filename representation path:');
|
||||||
if (fileRef) {
|
if (fileRef) {
|
||||||
alert('Uploaded successfully. Metadata schema verification committed.');
|
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"
|
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"
|
||||||
@@ -573,7 +578,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
alert('Template documentation initiated.');
|
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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ export default function OrderDetailsModal({ order, onClose }: OrderDetailsModalP
|
|||||||
const deliveryFee = fnum(order.deliveryfee) || fnum(order.deliverycharge) || 0;
|
const deliveryFee = fnum(order.deliveryfee) || fnum(order.deliverycharge) || 0;
|
||||||
const address = fstr(order.shippingaddress) || fstr(order.address) || 'No address provided';
|
const address = fstr(order.shippingaddress) || fstr(order.address) || 'No address provided';
|
||||||
|
|
||||||
const { data: details, isLoading } = useFiestaOrderDetails(order.orderheaderid);
|
// Cast rather than coerce: the hook itself does `orderheaderid as number |
|
||||||
|
// string` internally, and running it through fstr/fnum here would change the
|
||||||
|
// query key for every order that arrives as a number.
|
||||||
|
const { data: details, isLoading } = useFiestaOrderDetails(order.orderheaderid as number | string);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="odm-overlay">
|
<div className="odm-overlay">
|
||||||
|
|||||||
@@ -69,6 +69,20 @@ import {
|
|||||||
type PosTerminal,
|
type PosTerminal,
|
||||||
} from '../services/posApi';
|
} from '../services/posApi';
|
||||||
import PosStaffPanel from './PosStaffPanel';
|
import PosStaffPanel from './PosStaffPanel';
|
||||||
|
// PHASE 6 — POS console tabs on Astryx.
|
||||||
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
|
import { Card as AxCard } from '@astryxdesign/core/Card';
|
||||||
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
|
import { Link } from '@astryxdesign/core/Link';
|
||||||
|
import {
|
||||||
|
Table as AxTable,
|
||||||
|
TableHeader,
|
||||||
|
TableHeaderCell,
|
||||||
|
TableRow,
|
||||||
|
TableBody,
|
||||||
|
} from '@astryxdesign/core/Table';
|
||||||
import {
|
import {
|
||||||
type PosOutletHealth,
|
type PosOutletHealth,
|
||||||
usePosCatalogue,
|
usePosCatalogue,
|
||||||
@@ -320,18 +334,20 @@ export default function PosConsoleView({
|
|||||||
</Pill>
|
</Pill>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
<button
|
{/* IconButton still takes `label` (inherited from ButtonProps) —
|
||||||
|
it becomes the accessible name rather than visible text, which
|
||||||
|
the old `title`-only button never had. */}
|
||||||
|
<IconButton
|
||||||
|
label="Refresh"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={<RefreshCcw size={13} className={summaryQ.isFetching ? 'animate-spin' : ''} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
healthQ.refetch();
|
healthQ.refetch();
|
||||||
summaryQ.refetch();
|
summaryQ.refetch();
|
||||||
salesQ.refetch();
|
salesQ.refetch();
|
||||||
}}
|
}}
|
||||||
title="Refresh"
|
/>
|
||||||
className="inline-flex items-center justify-center rounded-full cursor-pointer transition-colors"
|
|
||||||
style={{ width: 30, height: 30, background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}`, color: BRAND }}
|
|
||||||
>
|
|
||||||
<RefreshCcw size={13} className={summaryQ.isFetching ? 'animate-spin' : ''} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -380,9 +396,13 @@ export default function PosConsoleView({
|
|||||||
<div className="text-[13px]" style={{ color: TEXT }}>
|
<div className="text-[13px]" style={{ color: TEXT }}>
|
||||||
<strong>{pendingTotal}</strong> bill{pendingTotal === 1 ? '' : 's'} still sitting on{' '}
|
<strong>{pendingTotal}</strong> bill{pendingTotal === 1 ? '' : 's'} still sitting on{' '}
|
||||||
{strandedTills} till{strandedTills === 1 ? '' : 's'} and not yet synced.
|
{strandedTills} till{strandedTills === 1 ? '' : 's'} and not yet synced.
|
||||||
<button onClick={() => setTab('terminals')} className="ml-2 underline font-semibold cursor-pointer" style={{ color: BRAND }}>
|
{/* Reads as a link mid-sentence but performs an in-page action, so
|
||||||
See which
|
it stays a <button> underneath — Link renders as one when given
|
||||||
</button>
|
no href, which is the correct element for something that changes
|
||||||
|
a tab rather than navigating. */}
|
||||||
|
<span className="ml-2">
|
||||||
|
<Link onClick={() => setTab('terminals')}>See which</Link>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -983,54 +1003,60 @@ function BillsTab(props: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
{/* PHASE 6 — the bills filter bar.
|
||||||
<div className="flex items-center gap-1.5 rounded-full px-3 py-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
`items-end` rather than `items-center`: Astryx fields carry a label
|
||||||
<Barcode size={13} style={{ color: TEXT_3 }} />
|
above the control, so centring would stagger them against the
|
||||||
<input
|
plain text on the right. */}
|
||||||
|
<div className="flex items-end gap-2 flex-wrap">
|
||||||
|
<div className="w-64">
|
||||||
|
<TextInput
|
||||||
|
label="Find a bill"
|
||||||
value={reference}
|
value={reference}
|
||||||
onChange={(e) => setReference(e.target.value)}
|
onChange={(v) => setReference(v)}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && onLookup()}
|
onKeyDown={(e) => e.key === 'Enter' && onLookup()}
|
||||||
placeholder="Invoice, order UUID or bill id…"
|
placeholder="Invoice, order UUID or bill id…"
|
||||||
className="bg-transparent outline-none text-[12.5px] w-56"
|
startIcon={<Barcode size={13} />}
|
||||||
style={{ color: TEXT }}
|
// Replaces the separate magnifier button beside the field. The
|
||||||
|
// clear affordance now belongs to the input, and Enter still
|
||||||
|
// runs the lookup exactly as before.
|
||||||
|
hasClear
|
||||||
/>
|
/>
|
||||||
<button onClick={onLookup} className="cursor-pointer" title="Look up bill" style={{ color: BRAND }}>
|
|
||||||
<Search size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select
|
<div className="w-44">
|
||||||
|
<Selector
|
||||||
|
label="Terminal"
|
||||||
value={terminalFilter}
|
value={terminalFilter}
|
||||||
onChange={(e) => setTerminalFilter(e.target.value)}
|
onChange={(v) => setTerminalFilter(String(v ?? ''))}
|
||||||
className="rounded-full px-3 py-1.5 text-[12.5px] font-semibold cursor-pointer outline-none"
|
placeholder="All terminals"
|
||||||
style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}`, color: TEXT }}
|
options={[
|
||||||
>
|
{ value: '', label: 'All terminals' },
|
||||||
<option value="">All terminals</option>
|
...terminalOptions.filter(Boolean).map((t) => ({ value: t, label: t })),
|
||||||
{terminalOptions.filter(Boolean).map((t) => (
|
]}
|
||||||
<option key={t} value={t}>{t}</option>
|
/>
|
||||||
))}
|
</div>
|
||||||
</select>
|
|
||||||
|
|
||||||
<select
|
<div className="w-40">
|
||||||
|
<Selector
|
||||||
|
label="Tender"
|
||||||
value={modeFilter}
|
value={modeFilter}
|
||||||
onChange={(e) => setModeFilter(e.target.value)}
|
onChange={(v) => setModeFilter(String(v ?? ''))}
|
||||||
className="rounded-full px-3 py-1.5 text-[12.5px] font-semibold cursor-pointer outline-none"
|
placeholder="All tenders"
|
||||||
style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}`, color: TEXT }}
|
options={[
|
||||||
>
|
{ value: '', label: 'All tenders' },
|
||||||
<option value="">All tenders</option>
|
...['cash', 'card', 'upi', 'wallet'].map((m) => ({ value: m, label: m })),
|
||||||
{['cash', 'card', 'upi', 'wallet'].map((m) => (
|
]}
|
||||||
<option key={m} value={m}>{m}</option>
|
/>
|
||||||
))}
|
</div>
|
||||||
</select>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 rounded-full px-3 py-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
<div className="w-48">
|
||||||
<Users size={13} style={{ color: TEXT_3 }} />
|
<TextInput
|
||||||
<input
|
label="Cashier"
|
||||||
value={cashierFilter}
|
value={cashierFilter}
|
||||||
onChange={(e) => setCashierFilter(e.target.value)}
|
onChange={(v) => setCashierFilter(v)}
|
||||||
placeholder="Cashier (exact name)"
|
placeholder="Exact name"
|
||||||
className="bg-transparent outline-none text-[12.5px] w-40"
|
startIcon={<Users size={13} />}
|
||||||
style={{ color: TEXT }}
|
hasClear
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1071,22 +1097,23 @@ function BillsTab(props: {
|
|||||||
Page {pageno + 1} of {pages}
|
Page {pageno + 1} of {pages}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{/* `secondary` rather than the tinted-brand pills: these sit
|
||||||
disabled={pageno === 0}
|
under a table as navigation, not as the primary action on
|
||||||
|
the page, and Astryx's disabled state is built in. */}
|
||||||
|
<Button
|
||||||
|
label="Previous"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
isDisabled={pageno === 0}
|
||||||
onClick={() => setPageno(pageno - 1)}
|
onClick={() => setPageno(pageno - 1)}
|
||||||
className="rounded-full px-3 py-1 text-[12px] font-bold cursor-pointer disabled:opacity-40 disabled:cursor-default"
|
/>
|
||||||
style={{ background: tint(BRAND), color: BRAND, border: `1px solid ${edge(BRAND)}` }}
|
<Button
|
||||||
>
|
label="Next"
|
||||||
Previous
|
variant="secondary"
|
||||||
</button>
|
size="sm"
|
||||||
<button
|
isDisabled={pageno + 1 >= pages}
|
||||||
disabled={pageno + 1 >= pages}
|
|
||||||
onClick={() => setPageno(pageno + 1)}
|
onClick={() => setPageno(pageno + 1)}
|
||||||
className="rounded-full px-3 py-1 text-[12px] font-bold cursor-pointer disabled:opacity-40 disabled:cursor-default"
|
/>
|
||||||
style={{ background: tint(BRAND), color: BRAND, border: `1px solid ${edge(BRAND)}` }}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
@@ -1187,17 +1214,20 @@ function BillDetail({ bill, loading, reference }: { bill: PosBill | null; loadin
|
|||||||
{/* The drawer clips horizontally, so the line table scrolls inside
|
{/* The drawer clips horizontally, so the line table scrolls inside
|
||||||
its own box rather than being cut off at the panel edge. */}
|
its own box rather than being cut off at the panel edge. */}
|
||||||
<div className="mt-2 rounded-xl border overflow-x-auto" style={{ borderColor: BORDER }}>
|
<div className="mt-2 rounded-xl border overflow-x-auto" style={{ borderColor: BORDER }}>
|
||||||
<table className="w-full text-[12px]" style={{ minWidth: 420 }}>
|
{/* The bill's line items. Header cells move to Astryx; the rows
|
||||||
<thead>
|
below stay as the caller's own <tr>, so the money formatting
|
||||||
<tr style={{ background: SURFACE_ALT, color: TEXT_2 }}>
|
and the barcode sub-line are untouched. */}
|
||||||
<th className="px-3 py-2 text-left font-extrabold">Item</th>
|
<AxTable style={{ minWidth: 420 }}>
|
||||||
<th className="px-3 py-2 text-right font-extrabold">Qty</th>
|
<TableHeader>
|
||||||
<th className="px-3 py-2 text-right font-extrabold">Rate</th>
|
<TableRow>
|
||||||
<th className="px-3 py-2 text-right font-extrabold">GST</th>
|
<TableHeaderCell><span className="block">Item</span></TableHeaderCell>
|
||||||
<th className="px-3 py-2 text-right font-extrabold">Total</th>
|
<TableHeaderCell><span className="block text-right">Qty</span></TableHeaderCell>
|
||||||
</tr>
|
<TableHeaderCell><span className="block text-right">Rate</span></TableHeaderCell>
|
||||||
</thead>
|
<TableHeaderCell><span className="block text-right">GST</span></TableHeaderCell>
|
||||||
<tbody className="divide-y" style={{ borderColor: DIVIDER }}>
|
<TableHeaderCell><span className="block text-right">Total</span></TableHeaderCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<tr key={it.posorderitemid}>
|
<tr key={it.posorderitemid}>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
@@ -1219,8 +1249,8 @@ function BillDetail({ bill, loading, reference }: { bill: PosBill | null; loadin
|
|||||||
<td className="px-3 py-2 text-right font-bold" style={{ color: TEXT }}>{inr(it.linetotal)}</td>
|
<td className="px-3 py-2 text-right font-bold" style={{ color: TEXT }}>{inr(it.linetotal)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</AxTable>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1460,25 +1490,27 @@ function PosTable({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border overflow-hidden shadow-sm w-full min-w-0" style={{ borderColor: BORDER }}>
|
// PHASE 6 — one conversion covers the Terminals, Bills and Catalogue tabs,
|
||||||
|
// since all three render through this. The hardcoded `bg-white` is gone
|
||||||
|
// with it: Card takes its surface from the theme, so this is one of the
|
||||||
|
// places that no longer blocks dark mode.
|
||||||
|
<AxCard padding={0} className="overflow-hidden w-full min-w-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm whitespace-nowrap" style={{ minWidth }}>
|
<AxTable style={{ minWidth }}>
|
||||||
<thead style={TH_STYLE}>
|
<TableHeader>
|
||||||
<tr>
|
<TableRow>
|
||||||
{columns.map((c, i) => (
|
{columns.map((c, i) => (
|
||||||
<th key={i} className={`px-4 py-3 ${c.align === 'right' ? 'text-right' : 'text-left'}`}>
|
<TableHeaderCell key={i}>
|
||||||
{c.label}
|
<span className={c.align === 'right' ? 'block text-right' : 'block'}>{c.label}</span>
|
||||||
</th>
|
</TableHeaderCell>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</TableRow>
|
||||||
</thead>
|
</TableHeader>
|
||||||
<tbody className="divide-y" style={{ borderColor: DIVIDER }}>
|
<TableBody>{children}</TableBody>
|
||||||
{children}
|
</AxTable>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</AxCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { Card as AxCard } from '@astryxdesign/core/Card';
|
||||||
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
|
import { Heading } from '@astryxdesign/core/Heading';
|
||||||
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -29,15 +33,11 @@ interface State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class PosErrorBoundary extends React.Component<Props, State> {
|
export default class PosErrorBoundary extends React.Component<Props, State> {
|
||||||
// This project has no `@types/react` installed, so `React.Component`'s
|
// The `declare props` / `declare setState` workaround that used to sit here
|
||||||
// inherited members are not typed and `this.props` / `this.setState` do not
|
// is gone. It existed only because the project had no `@types/react`, so
|
||||||
// resolve. (It is the same gap that makes `key` unassignable on a custom
|
// React.Component's inherited members did not resolve. Phase 0 installed the
|
||||||
// component, which is why the codebase wraps keyed elements in fragments.)
|
// types, so the real ones now apply and the shim was actively misleading —
|
||||||
// Re-declared here rather than cast at each use — `declare` emits nothing and
|
// it narrowed setState to a shape React does not actually guarantee.
|
||||||
// keeps the bodies below honest. Delete once the types are added.
|
|
||||||
declare props: Props;
|
|
||||||
declare setState: (state: State) => void;
|
|
||||||
|
|
||||||
state: State = { error: null };
|
state: State = { error: null };
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): State {
|
static getDerivedStateFromError(error: Error): State {
|
||||||
@@ -54,28 +54,30 @@ export default class PosErrorBoundary extends React.Component<Props, State> {
|
|||||||
if (!this.state.error) return this.props.children;
|
if (!this.state.error) return this.props.children;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// PHASE 6 — Card + Button. The hardcoded `bg-white` and `#662582` are
|
||||||
|
// gone, so the fallback follows the theme like everything else. That
|
||||||
|
// matters more here than elsewhere: this is the screen someone sees when
|
||||||
|
// things have already gone wrong, and a white slab on a dark page would
|
||||||
|
// read as a second failure.
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
<div className="bg-white border border-slate-200/70 p-10 text-center max-w-md shadow-[0_10px_40px_rgba(0,0,0,0.08)] rounded-2xl">
|
<AxCard maxWidth={448}>
|
||||||
|
<div className="p-10 text-center">
|
||||||
<div className="mx-auto h-16 w-16 rounded-2xl bg-rose-50 text-rose-600 ring-1 ring-rose-100 flex items-center justify-center mb-6">
|
<div className="mx-auto h-16 w-16 rounded-2xl bg-rose-50 text-rose-600 ring-1 ring-rose-100 flex items-center justify-center mb-6">
|
||||||
<AlertTriangle size={30} />
|
<AlertTriangle size={30} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900 tracking-tight mb-3">
|
<Heading level={2}>The POS screen hit a problem</Heading>
|
||||||
The POS screen hit a problem
|
<div className="mt-3 mb-2">
|
||||||
</h1>
|
<Text>
|
||||||
<p className="text-[15px] text-slate-500 leading-relaxed mb-2">
|
Counter sales are unaffected — bills keep syncing from the tills whatever this
|
||||||
Counter sales are unaffected — bills keep syncing from the tills whatever this page
|
page does. Only the display failed.
|
||||||
does. Only the display failed.
|
</Text>
|
||||||
</p>
|
</div>
|
||||||
<p className="text-[12px] text-slate-400 font-mono break-words mb-6">
|
<p className="text-[12px] font-mono break-words mb-6 opacity-60">
|
||||||
{this.state.error.message}
|
{this.state.error.message}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button label="Try again" onClick={() => this.setState({ error: null })} />
|
||||||
onClick={() => this.setState({ error: null })}
|
|
||||||
className="px-5 py-2.5 bg-[#662582] hover:bg-purple-800 text-white text-sm font-bold rounded-xl cursor-pointer transition-colors shadow-sm"
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</AxCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
// PHASE 6 — this page's form is the first converted to Astryx.
|
||||||
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
|
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 {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Check,
|
Check,
|
||||||
@@ -106,6 +112,19 @@ export default function PosStaffPanel({
|
|||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [shiftId, setShiftId] = useState(0);
|
const [shiftId, setShiftId] = useState(0);
|
||||||
const [formError, setFormError] = useState('');
|
const [formError, setFormError] = useState('');
|
||||||
|
/**
|
||||||
|
* PHASE 6 — per-field errors, replacing the single `formError` string for the
|
||||||
|
* create form.
|
||||||
|
*
|
||||||
|
* The old validation returned on the first failure, so an empty form told you
|
||||||
|
* about the name, then about the role once you fixed it, then about the PIN.
|
||||||
|
* Four round trips to learn four things. Astryx's inputs take a `status`, so
|
||||||
|
* every failure can be shown at once, against the field that caused it.
|
||||||
|
*
|
||||||
|
* `formError` stays for the edit and reset dialogs, which are not converted
|
||||||
|
* yet — Phase 6 moves one form at a time.
|
||||||
|
*/
|
||||||
|
const [fieldErrs, setFieldErrs] = useState<Partial<Record<'name' | 'role' | 'pin' | 'phone', string>>>({});
|
||||||
const [issued, setIssued] = useState<PosStaffUser | null>(null);
|
const [issued, setIssued] = useState<PosStaffUser | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [confirmOff, setConfirmOff] = useState<PosStaffUser | null>(null);
|
const [confirmOff, setConfirmOff] = useState<PosStaffUser | null>(null);
|
||||||
@@ -138,29 +157,44 @@ export default function PosStaffPanel({
|
|||||||
setPhone('');
|
setPhone('');
|
||||||
setShiftId(0);
|
setShiftId(0);
|
||||||
setFormError('');
|
setFormError('');
|
||||||
|
// Clear the per-field errors too, or reopening the form shows the errors
|
||||||
|
// from the last attempt against fields that are now empty.
|
||||||
|
setFieldErrs({});
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
const name = fullName.trim();
|
const name = fullName.trim();
|
||||||
const chosen = role || (roles.length === 1 ? roles[0].role : '');
|
const chosen = role || (roles.length === 1 ? roles[0].role : '');
|
||||||
if (!name) return setFormError('Enter the person’s name.');
|
// Same rules, same wording, same order as before — the only change is that
|
||||||
if (!chosen) return setFormError('Choose a role.');
|
// every failure is collected and attached to its field instead of the first
|
||||||
if (!/^\d{4}$/.test(pin)) return setFormError('The PIN must be exactly 4 digits.');
|
// one short-circuiting the rest.
|
||||||
|
const errs: typeof fieldErrs = {};
|
||||||
|
|
||||||
|
if (!name) errs.name = 'Enter the person’s name.';
|
||||||
|
if (!chosen) errs.role = 'Choose a role.';
|
||||||
|
|
||||||
|
if (!/^\d{4}$/.test(pin)) {
|
||||||
|
errs.pin = 'The PIN must be exactly 4 digits.';
|
||||||
|
} else if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) {
|
||||||
// Checked here rather than left to the server: two people sharing a PIN at
|
// Checked here rather than left to the server: two people sharing a PIN at
|
||||||
// one counter both appear on the till's sign-in list and neither can be
|
// one counter both appear on the till's sign-in list and neither can be
|
||||||
// told apart on a bill.
|
// told apart on a bill.
|
||||||
if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) {
|
errs.pin = 'Another active person at this outlet already uses that PIN.';
|
||||||
return setFormError('Another active person at this outlet already uses that PIN.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The till signs in with this, so it is required here even though the
|
// The till signs in with this, so it is required here even though the
|
||||||
// column allows blank — an account without one can only ever log in by
|
// column allows blank — an account without one can only ever log in by
|
||||||
// username, which is what this change exists to stop relying on.
|
// username, which is what this change exists to stop relying on.
|
||||||
const digits = phone.replace(/\D/g, '').replace(/^91(?=\d{10}$)/, '').replace(/^0(?=\d{10}$)/, '');
|
const digits = phone.replace(/\D/g, '').replace(/^91(?=\d{10}$)/, '').replace(/^0(?=\d{10}$)/, '');
|
||||||
if (digits.length !== 10) return setFormError('Enter a 10-digit mobile number.');
|
if (digits.length !== 10) {
|
||||||
if (staff.some((s) => (s.contactno || '') === digits && (s.status || '').toLowerCase() !== 'inactive')) {
|
errs.phone = 'Enter a 10-digit mobile number.';
|
||||||
return setFormError('Another active person here already signs in with that number.');
|
} else if (staff.some((s) => (s.contactno || '') === digits && (s.status || '').toLowerCase() !== 'inactive')) {
|
||||||
|
errs.phone = 'Another active person here already signs in with that number.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setFieldErrs(errs);
|
||||||
|
if (Object.keys(errs).length) return;
|
||||||
|
|
||||||
setFormError('');
|
setFormError('');
|
||||||
createMut.mutate(
|
createMut.mutate(
|
||||||
{
|
{
|
||||||
@@ -302,87 +336,87 @@ export default function PosStaffPanel({
|
|||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="mt-4 rounded-xl p-3" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
<div className="mt-4 rounded-xl p-3" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
||||||
|
{/* Every field now carries a real label rather than relying on a
|
||||||
|
placeholder. A placeholder disappears the moment you type, so
|
||||||
|
the only cue for what a half-filled field is went away exactly
|
||||||
|
when it was needed. */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
<input
|
<TextInput
|
||||||
|
label="Full name"
|
||||||
|
isRequired
|
||||||
value={fullName}
|
value={fullName}
|
||||||
onChange={(e) => setFullName(e.target.value)}
|
onChange={(v) => setFullName(v)}
|
||||||
placeholder="Full name"
|
placeholder="e.g. Anitha R"
|
||||||
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
|
status={fieldErrs.name ? { type: 'error', message: fieldErrs.name } : undefined}
|
||||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
|
||||||
/>
|
/>
|
||||||
<select
|
<Selector
|
||||||
|
label="Role"
|
||||||
|
isRequired
|
||||||
|
isDisabled={roles.length === 1}
|
||||||
value={role || (roles.length === 1 ? roles[0].role : '')}
|
value={role || (roles.length === 1 ? roles[0].role : '')}
|
||||||
onChange={(e) => setRole(e.target.value)}
|
onChange={(v) => setRole(String(v ?? ''))}
|
||||||
disabled={roles.length === 1}
|
placeholder="Choose a role…"
|
||||||
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white cursor-pointer disabled:cursor-default"
|
options={roles.map((r) => ({ value: r.role, label: r.label }))}
|
||||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
status={fieldErrs.role ? { type: 'error', message: fieldErrs.role } : undefined}
|
||||||
>
|
/>
|
||||||
{roles.length !== 1 && <option value="">Choose a role…</option>}
|
<TextInput
|
||||||
{roles.map((r) => (
|
label="PIN"
|
||||||
<option key={r.role_id} value={r.role}>{r.label}</option>
|
isRequired
|
||||||
))}
|
// The 4-digit clamp stays on the setter, not on the component —
|
||||||
</select>
|
// it is what stops a fifth keystroke ever reaching state.
|
||||||
<input
|
|
||||||
value={pin}
|
value={pin}
|
||||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, '').slice(0, 4))}
|
onChange={(v) => setPin(v.replace(/\D/g, '').slice(0, 4))}
|
||||||
placeholder="4-digit PIN"
|
placeholder="4 digits"
|
||||||
inputMode="numeric"
|
status={fieldErrs.pin ? { type: 'error', message: fieldErrs.pin } : undefined}
|
||||||
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white font-mono tracking-widest"
|
|
||||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
|
||||||
<input
|
<TextInput
|
||||||
|
label="Mobile number"
|
||||||
|
isRequired
|
||||||
|
description="What they type at the till to sign in."
|
||||||
value={phone}
|
value={phone}
|
||||||
onChange={(e) => setPhone(e.target.value.replace(/[^\d+\s-]/g, '').slice(0, 16))}
|
onChange={(v) => setPhone(v.replace(/[^\d+\s-]/g, '').slice(0, 16))}
|
||||||
placeholder="Mobile number — used to sign in"
|
placeholder="10 digits"
|
||||||
inputMode="tel"
|
status={fieldErrs.phone ? { type: 'error', message: fieldErrs.phone } : undefined}
|
||||||
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white font-mono"
|
/>
|
||||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
<Selector
|
||||||
|
label="Shift"
|
||||||
|
isOptional
|
||||||
|
value={String(shiftId)}
|
||||||
|
onChange={(v) => setShiftId(Number(v ?? 0))}
|
||||||
|
placeholder={shiftsQ.data?.length ? 'No shift assigned' : 'No shifts set up yet'}
|
||||||
|
options={[
|
||||||
|
{ value: '0', label: shiftsQ.data?.length ? 'No shift assigned' : 'No shifts set up yet' },
|
||||||
|
...(shiftsQ.data ?? []).map((sh) => ({
|
||||||
|
value: String(sh.staff_shift_id),
|
||||||
|
label: `${sh.name} · ${sh.start_time}–${sh.end_time}`,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<select
|
|
||||||
value={shiftId}
|
|
||||||
onChange={(e) => setShiftId(Number(e.target.value))}
|
|
||||||
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white cursor-pointer"
|
|
||||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
|
||||||
>
|
|
||||||
<option value={0}>
|
|
||||||
{shiftsQ.data?.length ? 'No shift assigned' : 'No shifts set up yet'}
|
|
||||||
</option>
|
|
||||||
{(shiftsQ.data ?? []).map((sh) => (
|
|
||||||
<option key={sh.staff_shift_id} value={sh.staff_shift_id}>
|
|
||||||
{sh.name} · {sh.start_time}–{sh.end_time}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[11px] mt-2" style={{ color: TEXT_3 }}>
|
<p className="text-[11px] mt-2" style={{ color: TEXT_3 }}>
|
||||||
The mobile number is what they type at the till. A username and password are also
|
A username and password are also generated; the password is shown once, on the
|
||||||
generated; the password is shown once, on the next screen.
|
next screen.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{formError && (
|
{/* The mobile-number hint moved onto the field itself as a
|
||||||
<p className="text-[12px] mt-2 font-semibold" style={{ color: '#ef4444' }}>{formError}</p>
|
`description`, so this paragraph no longer repeats it. */}
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-3">
|
<div className="flex flex-wrap items-center gap-2 mt-3">
|
||||||
<button
|
<Button
|
||||||
|
label={createMut.isPending ? 'Creating…' : 'Create account'}
|
||||||
|
isDisabled={createMut.isPending}
|
||||||
|
isLoading={createMut.isPending}
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={createMut.isPending}
|
/>
|
||||||
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50"
|
<Button
|
||||||
style={{ background: BRAND }}
|
label="Cancel"
|
||||||
>
|
variant="secondary"
|
||||||
{createMut.isPending ? 'Creating…' : 'Create account'}
|
onClick={() => { setShowForm(false); resetForm(); setFieldErrs({}); }}
|
||||||
</button>
|
/>
|
||||||
<button
|
|
||||||
onClick={() => { setShowForm(false); resetForm(); }}
|
|
||||||
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
|
|
||||||
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -503,71 +537,53 @@ export default function PosStaffPanel({
|
|||||||
{editing && (
|
{editing && (
|
||||||
<Modal onClose={() => setEditing(null)} title={`Edit ${editing.full_name}`}>
|
<Modal onClose={() => setEditing(null)} title={`Edit ${editing.full_name}`}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<input
|
<TextInput label="Full name" value={editName} onChange={(v) => setEditName(v)} />
|
||||||
value={editName}
|
<Selector
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
label="Role"
|
||||||
placeholder="Full name"
|
isDisabled={cashierOnly}
|
||||||
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none"
|
|
||||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={editRole}
|
value={editRole}
|
||||||
onChange={(e) => setEditRole(e.target.value)}
|
onChange={(v) => setEditRole(String(v ?? ''))}
|
||||||
disabled={cashierOnly}
|
options={roles.map((r) => ({ value: r.role, label: r.label }))}
|
||||||
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none cursor-pointer disabled:cursor-default"
|
/>
|
||||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
<TextInput
|
||||||
>
|
label="PIN"
|
||||||
{roles.map((r) => (
|
|
||||||
<option key={r.role_id} value={r.role}>{r.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
value={editPin}
|
value={editPin}
|
||||||
onChange={(e) => setEditPin(e.target.value.replace(/\D/g, '').slice(0, 4))}
|
onChange={(v) => setEditPin(v.replace(/\D/g, '').slice(0, 4))}
|
||||||
placeholder="4-digit PIN"
|
placeholder="4 digits"
|
||||||
inputMode="numeric"
|
|
||||||
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none font-mono tracking-widest"
|
|
||||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
|
||||||
/>
|
/>
|
||||||
<input
|
<TextInput
|
||||||
|
label="Mobile number"
|
||||||
|
description="What they type at the till to sign in."
|
||||||
value={editPhone}
|
value={editPhone}
|
||||||
onChange={(e) => setEditPhone(e.target.value.replace(/[^\d+\s-]/g, '').slice(0, 16))}
|
onChange={(v) => setEditPhone(v.replace(/[^\d+\s-]/g, '').slice(0, 16))}
|
||||||
placeholder="Mobile number — used to sign in"
|
/>
|
||||||
inputMode="tel"
|
<Selector
|
||||||
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none font-mono"
|
label="Shift"
|
||||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
isOptional
|
||||||
|
value={String(editShift)}
|
||||||
|
onChange={(v) => setEditShift(Number(v ?? 0))}
|
||||||
|
options={[
|
||||||
|
{ value: '0', label: 'No shift assigned' },
|
||||||
|
...(shiftsQ.data ?? []).map((sh) => ({
|
||||||
|
value: String(sh.staff_shift_id),
|
||||||
|
label: `${sh.name} · ${sh.start_time}–${sh.end_time}`,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<select
|
|
||||||
value={editShift}
|
|
||||||
onChange={(e) => setEditShift(Number(e.target.value))}
|
|
||||||
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none cursor-pointer"
|
|
||||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
|
||||||
>
|
|
||||||
<option value={0}>No shift assigned</option>
|
|
||||||
{(shiftsQ.data ?? []).map((sh) => (
|
|
||||||
<option key={sh.staff_shift_id} value={sh.staff_shift_id}>
|
|
||||||
{sh.name} · {sh.start_time}–{sh.end_time}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* editError stays a single message here. The edit dialog validates
|
||||||
|
one rule at a time against fields that are already populated, so
|
||||||
|
there is no four-errors-at-once problem to solve — unlike the
|
||||||
|
create form, which starts empty. */}
|
||||||
{editError && <p className="text-[12px] mt-2 font-semibold" style={{ color: '#ef4444' }}>{editError}</p>}
|
{editError && <p className="text-[12px] mt-2 font-semibold" style={{ color: '#ef4444' }}>{editError}</p>}
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-4">
|
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||||
<button
|
<Button
|
||||||
|
label={updateMut.isPending ? 'Saving…' : 'Save'}
|
||||||
|
isDisabled={updateMut.isPending}
|
||||||
|
isLoading={updateMut.isPending}
|
||||||
onClick={saveEdit}
|
onClick={saveEdit}
|
||||||
disabled={updateMut.isPending}
|
/>
|
||||||
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50"
|
<Button label="Cancel" variant="secondary" onClick={() => setEditing(null)} />
|
||||||
style={{ background: BRAND }}
|
|
||||||
>
|
|
||||||
{updateMut.isPending ? 'Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setEditing(null)}
|
|
||||||
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold cursor-pointer"
|
|
||||||
style={{ background: '#fff', color: TEXT_2, border: `1px solid ${BORDER}` }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
@@ -781,26 +797,28 @@ function ShiftManager({
|
|||||||
{open && (
|
{open && (
|
||||||
<div className="mt-4 rounded-xl p-3" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
<div className="mt-4 rounded-xl p-3" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
<input
|
<TextInput
|
||||||
|
label="Shift name"
|
||||||
|
isRequired
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(v) => setName(v)}
|
||||||
placeholder="Name — e.g. Morning"
|
placeholder="e.g. Morning"
|
||||||
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
|
|
||||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
|
||||||
/>
|
/>
|
||||||
<input
|
{/* PHASE 7's TimeInput, landing here rather than as a separate
|
||||||
type="time"
|
step — these two fields are the reason it was on the list.
|
||||||
value={start}
|
It emits an ISO time string or undefined, so the `?? ''`
|
||||||
onChange={(e) => setStart(e.target.value)}
|
keeps the existing string state contract unchanged. */}
|
||||||
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
|
<TimeInput
|
||||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
label="Starts"
|
||||||
|
isRequired
|
||||||
|
value={start as any}
|
||||||
|
onChange={(v) => setStart((v ?? '') as string)}
|
||||||
/>
|
/>
|
||||||
<input
|
<TimeInput
|
||||||
type="time"
|
label="Ends"
|
||||||
value={end}
|
isRequired
|
||||||
onChange={(e) => setEnd(e.target.value)}
|
value={end as any}
|
||||||
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
|
onChange={(v) => setEnd((v ?? '') as string)}
|
||||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -947,24 +965,24 @@ function Modal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
// PHASE 6 — on Astryx Dialog. All three dialogs on this page (edit, reset
|
||||||
|
// password, deactivate) render through here, so converting this one function
|
||||||
|
// converts all three without touching a single call site.
|
||||||
|
//
|
||||||
|
// isOpen is always true because callers mount this conditionally
|
||||||
|
// (`{editing && <Modal …>}`) rather than passing a flag. Dialog wants the
|
||||||
|
// flag, so it gets a constant one; the caller's conditional is still what
|
||||||
|
// controls visibility.
|
||||||
|
//
|
||||||
|
// The `minWidth: min(20rem, 100%)` floor is gone with the hand-rolled card.
|
||||||
|
// It existed to survive `max-w-sm` collapsing to 8px, and Dialog sizes itself
|
||||||
|
// from a `width` prop rather than a Tailwind utility — so the collision that
|
||||||
|
// bug came from cannot reach it.
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
|
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={400} purpose="info">
|
||||||
<div className="absolute inset-0 bg-slate-900/45" onClick={onClose} />
|
|
||||||
<div
|
|
||||||
className="relative bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6"
|
|
||||||
// A floor as well as a ceiling. `max-w-sm` was resolving to 8px until
|
|
||||||
// the utility was repaired in index.css, which collapsed this card to
|
|
||||||
// its min-content width; the floor means a future theme change cannot
|
|
||||||
// reproduce that, and `min(24rem, 100%)` still lets it shrink on a
|
|
||||||
// narrow phone rather than forcing a horizontal scroll.
|
|
||||||
style={{ border: `1px solid ${BORDER}`, minWidth: 'min(20rem, 100%)' }}
|
|
||||||
>
|
|
||||||
{/* Names come from user input and can be long and unbroken. */}
|
{/* Names come from user input and can be long and unbroken. */}
|
||||||
<h3 className="text-[16px] font-extrabold mb-3 break-words" style={{ color: TEXT }}>
|
<DialogHeader title={title} onOpenChange={(open) => { if (!open) onClose(); }} />
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</Dialog>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,51 @@ 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, Minus, Trash2, Receipt, X, Tag, QrCode, CreditCard, PieChart, Barcode, ShoppingCart, User, RefreshCcw, Save, Download, ChevronRight, Zap, Wallet, Smartphone, AlertCircle } from 'lucide-react';
|
||||||
import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
||||||
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||||
|
import { useNotify } from './Toasts';
|
||||||
|
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHASE 6 — the one modal shell for this screen.
|
||||||
|
*
|
||||||
|
* PosView carried SEVEN hand-rolled `fixed inset-0` overlays, the largest
|
||||||
|
* concentration anywhere in the app. Every one was the same construction: a
|
||||||
|
* blurred backdrop, a white card, an inline `maxWidth`, and a header row with a
|
||||||
|
* close button. None trapped focus, none closed on Escape, and each had to be
|
||||||
|
* dismissed by finding its own X.
|
||||||
|
*
|
||||||
|
* They now share this. Widths are passed per call site so each dialog keeps the
|
||||||
|
* exact size it had (500 / 450 / 400px), and `maxHeight` is preserved where the
|
||||||
|
* original set one — the two that scroll internally would otherwise grow past
|
||||||
|
* the viewport on a long list.
|
||||||
|
*
|
||||||
|
* `print:hidden` is intentionally NOT carried over. It was on the backdrop of
|
||||||
|
* every overlay including the receipt one, which is why the receipt modal had
|
||||||
|
* to fight it back with `print:max-w-none`. Dialog renders in the top layer,
|
||||||
|
* where the browser's own print handling applies.
|
||||||
|
*/
|
||||||
|
function PosModal({
|
||||||
|
onClose,
|
||||||
|
width,
|
||||||
|
maxHeight,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
width: number;
|
||||||
|
maxHeight?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen
|
||||||
|
onOpenChange={(open) => { if (!open) onClose(); }}
|
||||||
|
width={width}
|
||||||
|
maxHeight={maxHeight}
|
||||||
|
purpose="info"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface PosViewProps {
|
interface PosViewProps {
|
||||||
locationid?: number;
|
locationid?: number;
|
||||||
@@ -24,6 +69,10 @@ interface CartItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: PosViewProps) {
|
export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: PosViewProps) {
|
||||||
|
// 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();
|
||||||
|
|
||||||
const stockQ = useFiestaStockStatement({ tenantid: tenantId, locationid: locationid ?? 0, pagesize: 200 });
|
const stockQ = useFiestaStockStatement({ tenantid: tenantId, locationid: locationid ?? 0, pagesize: 200 });
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -236,7 +285,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
|||||||
if (product) {
|
if (product) {
|
||||||
handleProductScanned(product);
|
handleProductScanned(product);
|
||||||
} else {
|
} else {
|
||||||
alert('Product not found in inventory.');
|
notifyFail('Product not found in inventory.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -733,8 +782,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
|||||||
|
|
||||||
{/* ── MODAL: Manual Product Search ──────────────────────────────── */}
|
{/* ── MODAL: Manual Product Search ──────────────────────────────── */}
|
||||||
{showManualSearch && (
|
{showManualSearch && (
|
||||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
|
<PosModal onClose={() => setShowManualSearch(false)} width={500} maxHeight="85vh">
|
||||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[85vh] animate-in zoom-in-95 duration-200" style={{ width: '100%', maxWidth: '500px' }}>
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||||
<h3 className="font-bold text-slate-900 flex items-center gap-2">
|
<h3 className="font-bold text-slate-900 flex items-center gap-2">
|
||||||
<Search className="w-5 h-5 text-indigo-500" /> Product Search
|
<Search className="w-5 h-5 text-indigo-500" /> Product Search
|
||||||
@@ -781,14 +829,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PosModal>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── MODAL: Customer CRM ───────────────────────────────────────── */}
|
{/* ── MODAL: Customer CRM ───────────────────────────────────────── */}
|
||||||
{showCustomerPrompt && (
|
{showCustomerPrompt && (
|
||||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
|
<PosModal onClose={closeCustomerPrompt} width={400}>
|
||||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: '100%', maxWidth: '400px' }}>
|
|
||||||
<div className="px-6 pt-6 pb-5">
|
<div className="px-6 pt-6 pb-5">
|
||||||
<div className="flex justify-between items-start mb-5">
|
<div className="flex justify-between items-start mb-5">
|
||||||
<div className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center">
|
<div className="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center">
|
||||||
@@ -842,14 +888,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PosModal>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── MODAL: Parked Carts ───────────────────────────────────────── */}
|
{/* ── MODAL: Parked Carts ───────────────────────────────────────── */}
|
||||||
{showParkedPrompt && (
|
{showParkedPrompt && (
|
||||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
|
<PosModal onClose={() => setShowParkedPrompt(false)} width={450} maxHeight="80vh">
|
||||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh] animate-in zoom-in-95 duration-200" style={{ width: '100%', maxWidth: '450px' }}>
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||||
<h3 className="font-bold text-slate-900 flex items-center gap-2">
|
<h3 className="font-bold text-slate-900 flex items-center gap-2">
|
||||||
<Download className="w-5 h-5 text-blue-500" /> Parked Sales
|
<Download className="w-5 h-5 text-blue-500" /> Parked Sales
|
||||||
@@ -883,14 +927,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PosModal>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── MODAL: Discount ───────────────────────────────────────────── */}
|
{/* ── MODAL: Discount ───────────────────────────────────────────── */}
|
||||||
{discountPromptItem && (
|
{discountPromptItem && (
|
||||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
|
<PosModal onClose={() => setDiscountPromptItem(null)} width={400}>
|
||||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: '100%', maxWidth: '400px' }}>
|
|
||||||
<div className="px-6 pt-6 pb-5">
|
<div className="px-6 pt-6 pb-5">
|
||||||
<div className="flex justify-between items-start mb-5">
|
<div className="flex justify-between items-start mb-5">
|
||||||
<div className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center">
|
<div className="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center">
|
||||||
@@ -938,14 +980,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
|||||||
Confirm Discount
|
Confirm Discount
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PosModal>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── MODAL: Payment ────────────────────────────────────────────── */}
|
{/* ── MODAL: Payment ────────────────────────────────────────────── */}
|
||||||
{showPayment && (
|
{showPayment && (
|
||||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
|
<PosModal onClose={() => setShowPayment(false)} width={450}>
|
||||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: '100%', maxWidth: '450px' }}>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-white border-b border-slate-200 px-6 py-5 text-slate-900 flex items-start justify-between">
|
<div className="bg-white border-b border-slate-200 px-6 py-5 text-slate-900 flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -1104,14 +1144,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
|||||||
Confirm {grandTotal < 0 ? 'Refund' : 'Payment'}
|
Confirm {grandTotal < 0 ? 'Refund' : 'Payment'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PosModal>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── MODAL: Receipt ────────────────────────────────────────────── */}
|
{/* ── MODAL: Receipt ────────────────────────────────────────────── */}
|
||||||
{showReceipt && (
|
{showReceipt && (
|
||||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:static print:bg-white print:block print:p-0">
|
<PosModal onClose={resetPos} width={400}>
|
||||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden print:shadow-none print:max-w-none print:w-[300px]" style={{ width: '100%', maxWidth: '400px' }}>
|
|
||||||
<div className="px-5 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50 print:hidden">
|
<div className="px-5 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50 print:hidden">
|
||||||
<h3 className="font-bold text-slate-900 flex items-center gap-2">
|
<h3 className="font-bold text-slate-900 flex items-center gap-2">
|
||||||
<Receipt className="w-4 h-4" /> Receipt
|
<Receipt className="w-4 h-4" /> Receipt
|
||||||
@@ -1177,14 +1215,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
|||||||
<Receipt className="w-4 h-4" /> Print
|
<Receipt className="w-4 h-4" /> Print
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PosModal>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── MODAL: Daily Report ───────────────────────────────────────── */}
|
{/* ── MODAL: Daily Report ───────────────────────────────────────── */}
|
||||||
{showReport && (
|
{showReport && (
|
||||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
|
<PosModal onClose={() => setShowReport(false)} width={450}>
|
||||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: '100%', maxWidth: '450px' }}>
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||||
<h3 className="font-bold text-slate-900 flex items-center gap-2">
|
<h3 className="font-bold text-slate-900 flex items-center gap-2">
|
||||||
<PieChart className="w-5 h-5 text-emerald-500" /> Daily Sales Report
|
<PieChart className="w-5 h-5 text-emerald-500" /> Daily Sales Report
|
||||||
@@ -1274,8 +1310,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PosModal>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr,
|
|||||||
import { stockRowToProduct } from '../services/fiestaMappers';
|
import { stockRowToProduct } from '../services/fiestaMappers';
|
||||||
import AwaitingApi from './AwaitingApi';
|
import AwaitingApi from './AwaitingApi';
|
||||||
import SalesRevenueReport from './SalesRevenueReport';
|
import SalesRevenueReport from './SalesRevenueReport';
|
||||||
|
import { useNotify } from './Toasts';
|
||||||
|
|
||||||
interface ReportsViewProps {
|
interface ReportsViewProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -52,6 +53,10 @@ const WEEK_KEYS = ['week1', 'week2', 'week3', 'week4', 'week5'];
|
|||||||
const WEEK_LABELS = ['Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5'];
|
const WEEK_LABELS = ['Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5'];
|
||||||
|
|
||||||
export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView, tenantId = FIESTA_TENANT_ID }: ReportsViewProps) {
|
export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView, tenantId = FIESTA_TENANT_ID }: ReportsViewProps) {
|
||||||
|
// 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();
|
||||||
|
|
||||||
const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)');
|
const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)');
|
||||||
const [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all');
|
const [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all');
|
||||||
const [activeStoreId, setActiveStoreId] = useState<number | 'all'>('all');
|
const [activeStoreId, setActiveStoreId] = useState<number | 'all'>('all');
|
||||||
@@ -433,7 +438,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setExportingFormat(null);
|
setExportingFormat(null);
|
||||||
alert(`${format} ledger exported successfully.`);
|
notifyOk(`${format} ledger exported.`);
|
||||||
}, 450);
|
}, 450);
|
||||||
return 100;
|
return 100;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ export default function RiderTelemetryPanel({ logs, riderName, isLoading }: Ride
|
|||||||
const lat = latest?.latitude || latest?.lat;
|
const lat = latest?.latitude || latest?.lat;
|
||||||
const lon = latest?.longitude || latest?.lng;
|
const lon = latest?.longitude || latest?.lng;
|
||||||
const battery = latest?.batterypercentage || latest?.battery;
|
const battery = latest?.batterypercentage || latest?.battery;
|
||||||
const signal = latest?.signalstrength || latest?.signal;
|
// Typed at the declaration rather than cast at each use. The telemetry rows
|
||||||
|
// are untyped, so without this `signal` is `unknown` and cannot be rendered
|
||||||
|
// or passed to getSignalBars. The value is a dBm reading or absent.
|
||||||
|
const signal = (latest?.signalstrength || latest?.signal) as number | undefined;
|
||||||
const speed = latest?.speed || 0;
|
const speed = latest?.speed || 0;
|
||||||
const timestamp = latest?.logdate || latest?.timestamp;
|
const timestamp = latest?.logdate || latest?.timestamp;
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import AwaitingApi from './AwaitingApi';
|
|||||||
import AdminConsole from './AdminConsole';
|
import AdminConsole from './AdminConsole';
|
||||||
import { ConfirmModal } from './ConfirmModal';
|
import { ConfirmModal } from './ConfirmModal';
|
||||||
import type { AuthUser } from '../services/auth';
|
import type { AuthUser } from '../services/auth';
|
||||||
|
import { useNotify } from './Toasts';
|
||||||
|
|
||||||
type TabKey = 'profile' | 'outlets' | 'users';
|
type TabKey = 'profile' | 'outlets' | 'users';
|
||||||
|
|
||||||
@@ -128,6 +129,10 @@ export interface SettingsViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: SettingsViewProps) {
|
export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: SettingsViewProps) {
|
||||||
|
// 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();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>('profile');
|
const [activeTab, setActiveTab] = useState<TabKey>('profile');
|
||||||
|
|
||||||
// Live tenant profile + outlets.
|
// Live tenant profile + outlets.
|
||||||
@@ -166,9 +171,9 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
|||||||
});
|
});
|
||||||
setShowEditStoreModal(false);
|
setShowEditStoreModal(false);
|
||||||
setEditingStore(null);
|
setEditingStore(null);
|
||||||
alert('Outlet updated successfully!');
|
notifyOk('Outlet updated.');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to update outlet.');
|
notifyFail(err, 'Failed to update outlet.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -183,7 +188,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
|||||||
await deleteLocationMut.mutateAsync({ tenantid: tenantId, locationid: loc.locationid });
|
await deleteLocationMut.mutateAsync({ tenantid: tenantId, locationid: loc.locationid });
|
||||||
setConfirmModalState({ isOpen: false, loc: null });
|
setConfirmModalState({ isOpen: false, loc: null });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Failed to delete outlet.');
|
notifyFail(err, 'Failed to delete outlet.');
|
||||||
setConfirmModalState({ isOpen: false, loc: null });
|
setConfirmModalState({ isOpen: false, loc: null });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ import { CardCarousel, ProductGallery } from './CatalogueGallery';
|
|||||||
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
||||||
import CatalogueDetailPanel from './CatalogueDetailPanel';
|
import CatalogueDetailPanel from './CatalogueDetailPanel';
|
||||||
import OfflineSalesUpload from './OfflineSalesUpload';
|
import OfflineSalesUpload from './OfflineSalesUpload';
|
||||||
|
import { useNotify } from './Toasts';
|
||||||
|
// PHASE 6 — the store catalogue's own controls.
|
||||||
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
|
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||||
|
import { DateInput } from '@astryxdesign/core/DateInput';
|
||||||
|
import { CheckboxInput } from '@astryxdesign/core/CheckboxInput';
|
||||||
|
|
||||||
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
|
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
|
||||||
|
|
||||||
@@ -79,6 +85,10 @@ function catBadgeClass(category: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID, isSidebarOpen = false }: StoreCatalogViewProps) {
|
export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID, isSidebarOpen = false }: StoreCatalogViewProps) {
|
||||||
|
// 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();
|
||||||
|
|
||||||
const tenantid = tenantId;
|
const tenantid = tenantId;
|
||||||
const [view, setView] = useState<'catalogue' | 'inventory' | 'requests'>('catalogue');
|
const [view, setView] = useState<'catalogue' | 'inventory' | 'requests'>('catalogue');
|
||||||
const [isLocalSidebarOpen, setIsLocalSidebarOpen] = useState(false);
|
const [isLocalSidebarOpen, setIsLocalSidebarOpen] = useState(false);
|
||||||
@@ -146,7 +156,11 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
);
|
);
|
||||||
|
|
||||||
const stockRequestsQ = useFiestaGetStockRequests({ tenantid, locationid: storeLocationId, pagesize: 500, date: requestDate });
|
const stockRequestsQ = useFiestaGetStockRequests({ tenantid, locationid: storeLocationId, pagesize: 500, date: requestDate });
|
||||||
const [picks, setPicks] = useState<Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected' | 'Cancelled' | 'Received'; requestedAt: string; resolvedAt?: string }>>({});
|
// `requestid` is written by the effect below and read by the Mark Received
|
||||||
|
// button; it was simply missing from this type, so the read did not compile
|
||||||
|
// once React types were installed. The value has always been present at
|
||||||
|
// runtime — this closes the type gap, it does not change behaviour.
|
||||||
|
const [picks, setPicks] = useState<Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected' | 'Cancelled' | 'Received'; requestedAt: string; resolvedAt?: string; requestid?: number }>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stockRequestsQ.data) {
|
if (stockRequestsQ.data) {
|
||||||
@@ -187,7 +201,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
if (picks[String(data.productid)]?.status === 'Received') return;
|
if (picks[String(data.productid)]?.status === 'Received') return;
|
||||||
if (updateRequestMutation.isPending) return;
|
if (updateRequestMutation.isPending) return;
|
||||||
if (!storeLocationId) {
|
if (!storeLocationId) {
|
||||||
alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be received.');
|
notifyFail('Your account isn\'t linked to a store outlet yet, so stock can\'t be received.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateRequestMutation.mutate(
|
updateRequestMutation.mutate(
|
||||||
@@ -200,7 +214,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
alert(err.message || 'Failed to record received stock.');
|
notifyFail(err, 'Failed to record received stock.');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -213,7 +227,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
setPicks(prev => ({ ...prev, [id]: { ...prev[id], status: 'Cancelled', resolvedAt: new Date().toISOString() } }));
|
setPicks(prev => ({ ...prev, [id]: { ...prev[id], status: 'Cancelled', resolvedAt: new Date().toISOString() } }));
|
||||||
} else {
|
} else {
|
||||||
if (!storeLocationId) {
|
if (!storeLocationId) {
|
||||||
alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.');
|
notifyFail('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPicks(prev => ({ ...prev, [id]: { qty: 1, status: 'Pending', requestedAt: new Date().toISOString() } }));
|
setPicks(prev => ({ ...prev, [id]: { qty: 1, status: 'Pending', requestedAt: new Date().toISOString() } }));
|
||||||
@@ -231,7 +245,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
const setPickQty = (id: string, qty: number) => {
|
const setPickQty = (id: string, qty: number) => {
|
||||||
const safeQty = Math.max(1, Math.round(qty) || 1);
|
const safeQty = Math.max(1, Math.round(qty) || 1);
|
||||||
if (!storeLocationId) {
|
if (!storeLocationId) {
|
||||||
alert('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.');
|
notifyFail('Your account isn\'t linked to a store outlet yet, so stock can\'t be requested.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPicks((prev) => {
|
setPicks((prev) => {
|
||||||
@@ -404,14 +418,16 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
<UploadCloud size={14} /> Upload Offline Sales
|
<UploadCloud size={14} /> Upload Offline Sales
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* PHASE 6 — DateInput carries its own label, so the separate "Date:"
|
||||||
|
caption is dropped rather than duplicated beside it. The comment sits
|
||||||
|
outside the conditional: as its first child it read as a second root
|
||||||
|
element and broke the whole file. */}
|
||||||
{view === 'requests' && (
|
{view === 'requests' && (
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="shrink-0 w-44">
|
||||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">Date:</span>
|
<DateInput
|
||||||
<input
|
label="Date"
|
||||||
type="date"
|
value={requestDate as any}
|
||||||
value={requestDate}
|
onChange={(v) => setRequestDate((v ?? '') as string)}
|
||||||
onChange={(e) => setRequestDate(e.target.value)}
|
|
||||||
className="px-3 py-1.5 border border-slate-200 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-[#662582] focus:border-[#662582] text-xs font-semibold text-slate-700 bg-white"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -436,20 +452,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<label key={c} className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-50 cursor-pointer group transition-colors">
|
<div key={c} className="px-1 py-0.5">
|
||||||
<div className="relative flex items-center justify-center">
|
<CheckboxInput
|
||||||
<input
|
label={c}
|
||||||
type="checkbox"
|
value={selectedCategories.includes(c)}
|
||||||
checked={selectedCategories.includes(c)}
|
|
||||||
onChange={() => toggleCategory(c)}
|
onChange={() => toggleCategory(c)}
|
||||||
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>
|
</div>
|
||||||
<span className={`text-xs font-semibold select-none transition-colors ${selectedCategories.includes(c) ? 'text-[#662582]' : 'text-slate-600 group-hover:text-slate-900'}`}>
|
|
||||||
{c}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -463,20 +472,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{['Healthy', 'Low', 'Critical', 'Out of stock'].map((h) => (
|
{['Healthy', 'Low', 'Critical', 'Out of stock'].map((h) => (
|
||||||
<label key={h} className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-50 cursor-pointer group transition-colors">
|
<div key={h} className="px-1 py-0.5">
|
||||||
<div className="relative flex items-center justify-center">
|
<CheckboxInput
|
||||||
<input
|
label={h}
|
||||||
type="checkbox"
|
value={stockHealthFilter.includes(h)}
|
||||||
checked={stockHealthFilter.includes(h)}
|
|
||||||
onChange={() => toggleStockHealth(h)}
|
onChange={() => toggleStockHealth(h)}
|
||||||
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>
|
</div>
|
||||||
<span className={`text-xs font-semibold select-none transition-colors ${stockHealthFilter.includes(h) ? 'text-[#662582]' : 'text-slate-600 group-hover:text-slate-900'}`}>
|
|
||||||
{h}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -521,20 +523,20 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
<Layers size={14} /> Filter
|
<Layers size={14} /> Filter
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="relative hidden md:block md:w-64 h-[36px]">
|
{/* `hasClear` replaces the absolutely-positioned X button, and
|
||||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
`startIcon` replaces the absolutely-positioned magnifier — so
|
||||||
<input
|
the wrapper no longer needs `relative` or a fixed height to
|
||||||
type="text"
|
anchor them. */}
|
||||||
placeholder={view === 'catalogue' ? 'Search catalogue...' : 'Search your stock...'}
|
<div className="hidden md:block md:w-64">
|
||||||
|
<TextInput
|
||||||
|
label="Search"
|
||||||
|
isLabelHidden
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(v) => setSearch(v)}
|
||||||
className="w-full h-full pl-9 pr-8 py-2 bg-white border border-slate-200 rounded-xl text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-[#662582] transition-all shadow-sm font-medium"
|
placeholder={view === 'catalogue' ? 'Search catalogue…' : 'Search your stock…'}
|
||||||
|
startIcon={<Search size={14} />}
|
||||||
|
hasClear
|
||||||
/>
|
/>
|
||||||
{search && (
|
|
||||||
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer border-none bg-transparent">
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -881,24 +883,15 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* NumberInput enforces the minimum itself, so the Math.max guard
|
||||||
<label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 block mb-2">Custom Amount</label>
|
stays only as the floor for a pasted or cleared value. */}
|
||||||
<div className="relative group">
|
<NumberInput
|
||||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
|
label="Custom quantity"
|
||||||
<Boxes size={14} className="text-slate-400 group-focus-within:text-[#662582] transition-colors" />
|
min={1}
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={tempQty}
|
value={tempQty}
|
||||||
onChange={(e) => setTempQty(Math.max(1, parseInt(e.target.value) || 1))}
|
onChange={(v) => setTempQty(Math.max(1, Number(v) || 1))}
|
||||||
className="w-full h-10 pl-9 pr-12 text-right text-lg font-bold font-mono text-[#662582] bg-slate-50/50 rounded-lg border border-slate-200 outline-none focus:border-[#662582] focus:bg-white transition-all shadow-inner"
|
startIcon={<Boxes size={14} />}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-y-0 right-3 flex items-center pointer-events-none">
|
|
||||||
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest pl-2 border-l border-slate-200">Units</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Total & Submit */}
|
{/* Total & Submit */}
|
||||||
|
|||||||
@@ -3,16 +3,19 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHASE 4 — now a wrapper over the shared `NavRail`.
|
||||||
|
*
|
||||||
|
* The props and the rendered result are unchanged, so `SuperAdminPage` was not
|
||||||
|
* touched. The only behavioural difference is that tapping an item on a phone
|
||||||
|
* now closes the rail, which the user rail already did and this one didn't.
|
||||||
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { type LucideIcon } from 'lucide-react';
|
import NavRail, { type NavRailItem } from './NavRail';
|
||||||
import { NavLink } from 'react-router-dom';
|
|
||||||
|
|
||||||
/** One entry in the super admin sidebar. */
|
/** One entry in the super admin sidebar. */
|
||||||
export interface SuperAdminNavItem {
|
export type SuperAdminNavItem = NavRailItem;
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SuperAdminSidebarProps {
|
interface SuperAdminSidebarProps {
|
||||||
items: SuperAdminNavItem[];
|
items: SuperAdminNavItem[];
|
||||||
@@ -21,53 +24,6 @@ interface SuperAdminSidebarProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The Super Admin page's left rail. Visually identical to the admin `Sidebar`.
|
|
||||||
*/
|
|
||||||
export default function SuperAdminSidebar({ items, isOpen, onClose }: SuperAdminSidebarProps) {
|
export default function SuperAdminSidebar({ items, isOpen, onClose }: SuperAdminSidebarProps) {
|
||||||
return (
|
return <NavRail items={items} basePath="/superadmin" isOpen={isOpen} onClose={onClose} itemRadius="none" />;
|
||||||
<>
|
|
||||||
{/* Mobile Overlay */}
|
|
||||||
{isOpen && (
|
|
||||||
<div
|
|
||||||
className="md:hidden fixed inset-0 bg-black/50 z-40 backdrop-blur-sm"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<aside
|
|
||||||
className={`fixed left-0 top-0 h-screen bg-[#662582] border-r border-[#662582] text-white flex flex-col py-xl pt-16 z-40 transition-all duration-300 ${
|
|
||||||
isOpen ? 'translate-x-0 w-64' : '-translate-x-full md:translate-x-0 md:w-16'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Main Navigation Sidebar Links */}
|
|
||||||
<nav className="flex-1 space-y-1 overflow-y-auto px-xs">
|
|
||||||
{items.map((item) => {
|
|
||||||
const IconComponent = item.icon;
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={item.id}
|
|
||||||
to={`/superadmin/${item.id}`}
|
|
||||||
title={item.label}
|
|
||||||
className={({ isActive }) => `w-full flex items-center py-3 rounded-none text-left transition-all duration-200 cursor-pointer ${
|
|
||||||
isOpen ? 'gap-md px-md border-l-4' : 'justify-center px-0'
|
|
||||||
} ${
|
|
||||||
isActive
|
|
||||||
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-white' : '')
|
|
||||||
: 'text-purple-200 hover:bg-white/10 hover:text-white' + (isOpen ? ' border-transparent' : '')
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<>
|
|
||||||
<IconComponent size={18} className={isActive ? 'text-white' : 'text-purple-300'} />
|
|
||||||
{isOpen && <span className="font-sans text-sm font-medium">{item.label}</span>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/components/Toasts.tsx
Normal file
52
src/components/Toasts.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* PHASE 5 — toasts.
|
||||||
|
*
|
||||||
|
* The console has never had any. Publishing a product, importing from the
|
||||||
|
* catalogue, deactivating a till user and deleting a product location all
|
||||||
|
* complete in silence today: the only feedback is that a row eventually
|
||||||
|
* changes, or doesn't. When something fails, the user usually cannot tell
|
||||||
|
* whether it failed or simply hasn't finished.
|
||||||
|
*
|
||||||
|
* `ToastViewport` is mounted once in main.tsx. Anything below it can call
|
||||||
|
* `useToast()` and get a function that shows one. This file adds two thin
|
||||||
|
* helpers over that so call sites read as intent rather than configuration.
|
||||||
|
*
|
||||||
|
* Astryx's ToastType is only 'info' | 'error' — there is no success type — so
|
||||||
|
* `notifyOk` uses 'info'. Do not invent a 'success' string; it will not
|
||||||
|
* typecheck, and silently falling back would be worse.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useToast } from '@astryxdesign/core/Toast';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toasts for the result of a mutation.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { notifyOk, notifyFail } = useNotify();
|
||||||
|
* publishMut.mutate(vars, {
|
||||||
|
* onSuccess: () => notifyOk('Published to all outlets'),
|
||||||
|
* onError: (e) => notifyFail(e),
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useNotify() {
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifyOk: (message: string) => toast({ body: message, isAutoHide: true }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts the raw error so no call site has to remember how to unwrap one.
|
||||||
|
* Errors do NOT auto-hide: a failure the user blinked past is a failure
|
||||||
|
* they will report as "nothing happened".
|
||||||
|
*/
|
||||||
|
notifyFail: (err: unknown, fallback = 'Something went wrong. Please try again.') => {
|
||||||
|
const message =
|
||||||
|
err instanceof Error ? err.message
|
||||||
|
: typeof err === 'string' ? err
|
||||||
|
: fallback;
|
||||||
|
return toast({ body: message, type: 'error', isAutoHide: false });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast };
|
||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Package, MapPin, Truck, ChevronDown, Check, X } from 'lucide-react';
|
import { Package, MapPin, Truck, ChevronDown, Check, X } from 'lucide-react';
|
||||||
import { useFiestaTenantLocations } from '../services/fiestaQueries';
|
import { useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||||
import { str as fstr } from '../services/fiestaApi';
|
import { str as fstr } from '../services/fiestaApi';
|
||||||
|
import { useNotify } from './Toasts';
|
||||||
|
|
||||||
export interface TrialProduct {
|
export interface TrialProduct {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@@ -21,6 +22,10 @@ interface TrialBatchDrawerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TrialBatchDrawer({ isOpen, onClose, product, tenantId, onSuccess }: TrialBatchDrawerProps) {
|
export default function TrialBatchDrawer({ isOpen, onClose, product, tenantId, onSuccess }: TrialBatchDrawerProps) {
|
||||||
|
// 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();
|
||||||
|
|
||||||
const [qty, setQty] = useState(5);
|
const [qty, setQty] = useState(5);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isSuccess, setIsSuccess] = useState(false);
|
const [isSuccess, setIsSuccess] = useState(false);
|
||||||
@@ -49,7 +54,7 @@ export default function TrialBatchDrawer({ isOpen, onClose, product, tenantId, o
|
|||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (selectedLocationIds.length === 0) {
|
if (selectedLocationIds.length === 0) {
|
||||||
alert("Please select at least one store delivery hub.");
|
notifyFail('Select at least one store delivery hub.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import PosConsoleView from './PosConsoleView';
|
|||||||
import PosErrorBoundary from './PosErrorBoundary';
|
import PosErrorBoundary from './PosErrorBoundary';
|
||||||
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
||||||
import ComparisonModal from './ComparisonModal';
|
import ComparisonModal from './ComparisonModal';
|
||||||
|
import { useNotify } from './Toasts';
|
||||||
interface UserStorePageProps {
|
interface UserStorePageProps {
|
||||||
/** Returns to the login screen. */
|
/** Returns to the login screen. */
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
@@ -68,6 +69,10 @@ type StoreShape = React.ComponentProps<typeof StoreDetailView>['store'];
|
|||||||
* no other stores. Layout mirrors the admin console (fixed Header + left rail).
|
* no other stores. Layout mirrors the admin console (fixed Header + left rail).
|
||||||
*/
|
*/
|
||||||
export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||||
|
// 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();
|
||||||
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [showQrModal, setShowQrModal] = useState(false);
|
const [showQrModal, setShowQrModal] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -151,7 +156,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
.toUpperCase() || 'NA';
|
.toUpperCase() || 'NA';
|
||||||
|
|
||||||
const handleHelp = () =>
|
const handleHelp = () =>
|
||||||
alert('nearledaily Store User Help — contact your store administrator for access or data questions.');
|
notifyOk('Contact your store administrator for access or data questions.');
|
||||||
|
|
||||||
// Build the store shape StoreDetailView expects, mirroring the App registry
|
// Build the store shape StoreDetailView expects, mirroring the App registry
|
||||||
// mapping so the console renders identically to the admin's store view.
|
// mapping so the console renders identically to the admin's store view.
|
||||||
|
|||||||
@@ -3,16 +3,19 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHASE 4 — now a wrapper over the shared `NavRail`.
|
||||||
|
*
|
||||||
|
* Props and rendered result are unchanged, so `UserStorePage` was not touched.
|
||||||
|
* `itemRadius="lg"` preserves this rail's rounded items, which is where it
|
||||||
|
* differed from the super-admin one.
|
||||||
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { type LucideIcon } from 'lucide-react';
|
import NavRail, { type NavRailItem } from './NavRail';
|
||||||
import { NavLink } from 'react-router-dom';
|
|
||||||
|
|
||||||
/** One entry in the user store sidebar. Add to `UserStorePage`'s `NAV_ITEMS`. */
|
/** One entry in the user store sidebar. Add to `UserStorePage`'s `NAV_ITEMS`. */
|
||||||
export interface UserNavItem {
|
export type UserNavItem = NavRailItem;
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserStoreSidebarProps {
|
interface UserStoreSidebarProps {
|
||||||
items: UserNavItem[];
|
items: UserNavItem[];
|
||||||
@@ -21,60 +24,6 @@ interface UserStoreSidebarProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The user store page's left rail. Visually identical to the admin `Sidebar`
|
|
||||||
* (fixed, sits under the fixed Header, collapses to an icon rail) but driven by
|
|
||||||
* a generic items list so new user sections can be added without touching this.
|
|
||||||
*/
|
|
||||||
export default function UserStoreSidebar({ items, isOpen, onClose }: UserStoreSidebarProps) {
|
export default function UserStoreSidebar({ items, isOpen, onClose }: UserStoreSidebarProps) {
|
||||||
return (
|
return <NavRail items={items} basePath="/store" isOpen={isOpen} onClose={onClose} itemRadius="lg" />;
|
||||||
<>
|
|
||||||
{/* Mobile Overlay */}
|
|
||||||
{isOpen && (
|
|
||||||
<div
|
|
||||||
className="md:hidden fixed inset-0 bg-black/50 z-40 backdrop-blur-sm"
|
|
||||||
onClick={onClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<aside
|
|
||||||
className={`fixed left-0 top-0 h-screen bg-[#662582] border-r border-[#662582] text-white flex flex-col py-xl pt-16 z-40 transition-all duration-300 ${
|
|
||||||
isOpen ? 'translate-x-0 w-64' : '-translate-x-full md:translate-x-0 md:w-16'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Main Navigation Sidebar Links */}
|
|
||||||
<nav className="flex-1 space-y-1 overflow-y-auto px-xs">
|
|
||||||
{items.map((item) => {
|
|
||||||
const IconComponent = item.icon;
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={item.id}
|
|
||||||
to={`/store/${item.id}`}
|
|
||||||
title={item.label}
|
|
||||||
onClick={() => {
|
|
||||||
if (window.innerWidth < 768) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={({ isActive }) => `w-full flex items-center py-3 rounded-lg text-left transition-all duration-200 cursor-pointer ${
|
|
||||||
isOpen ? 'gap-md px-md border-l-4' : 'justify-center px-0'
|
|
||||||
} ${
|
|
||||||
isActive
|
|
||||||
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-white' : '')
|
|
||||||
: 'text-purple-200 hover:bg-white/10 hover:text-white' + (isOpen ? ' border-transparent' : '')
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<>
|
|
||||||
<IconComponent size={18} className={isActive ? 'text-white' : 'text-purple-300'} />
|
|
||||||
{isOpen && <span className="font-sans text-sm font-medium">{item.label}</span>}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { useFiestaUsers, useFiestaCreateUser, useFiestaUpdateUser, useFiestaRide
|
|||||||
import { useAppRoles } from '../services/queries';
|
import { useAppRoles } from '../services/queries';
|
||||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||||
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
|
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
|
||||||
|
import { useNotify } from './Toasts';
|
||||||
|
|
||||||
interface UsersPanelProps {
|
interface UsersPanelProps {
|
||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
@@ -83,6 +84,10 @@ const FALLBACK_ROLE_CHOICES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) {
|
export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) {
|
||||||
|
// 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();
|
||||||
|
|
||||||
const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 });
|
const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 });
|
||||||
const createUserMut = useFiestaCreateUser();
|
const createUserMut = useFiestaCreateUser();
|
||||||
const updateUserMut = useFiestaUpdateUser();
|
const updateUserMut = useFiestaUpdateUser();
|
||||||
@@ -105,7 +110,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
|||||||
await updateUserMut.mutateAsync({ userid, roleid: -1, locationid: -1, applocationid: -1 });
|
await updateUserMut.mutateAsync({ userid, roleid: -1, locationid: -1, applocationid: -1 });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`Could not update user role: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
notifyFail(err, 'Could not update user role.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -246,23 +251,23 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
|||||||
const handleCreateUser = async (e: React.FormEvent) => {
|
const handleCreateUser = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newUser.firstname || !newUser.email || !newUser.contactno) {
|
if (!newUser.firstname || !newUser.email || !newUser.contactno) {
|
||||||
alert('Please provide first name, contact number, and email.');
|
notifyFail('Please provide first name, contact number, and email.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!/^\d{10}$/.test(newUser.contactno.trim())) {
|
if (!/^\d{10}$/.test(newUser.contactno.trim())) {
|
||||||
alert('Contact number must be exactly 10 digits.');
|
notifyFail('Contact number must be exactly 10 digits.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newUser.email.trim())) {
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newUser.email.trim())) {
|
||||||
alert('Please enter a valid email address.');
|
notifyFail('Please enter a valid email address.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!Number(newUser.roleid)) {
|
if (!Number(newUser.roleid)) {
|
||||||
alert('Please select a role.');
|
notifyFail('Please select a role.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!newUser.locationid) {
|
if (!newUser.locationid) {
|
||||||
alert('Please select the store this user belongs to.');
|
notifyFail('Please select the store this user belongs to.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -288,9 +293,9 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
|||||||
});
|
});
|
||||||
setShowAddUserModal(false);
|
setShowAddUserModal(false);
|
||||||
setNewUser({ firstname: '', email: '', contactno: '', roleid: defaultNewUserRole, locationid: 0, applocationid: 0, address: '', suburb: '', city: '', state: '', postcode: '', latitude: '', longitude: '', shiftid: 0 });
|
setNewUser({ firstname: '', email: '', contactno: '', roleid: defaultNewUserRole, locationid: 0, applocationid: 0, address: '', suburb: '', city: '', state: '', postcode: '', latitude: '', longitude: '', shiftid: 0 });
|
||||||
alert(`Team member "${newUser.firstname}" added successfully.`);
|
notifyOk(`Team member "${newUser.firstname}" added.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`Could not add team member: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
notifyFail(err, 'Could not add team member.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -715,7 +720,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
|||||||
});
|
});
|
||||||
setAssignUserId(null);
|
setAssignUserId(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`Could not assign user: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
notifyFail(err, 'Could not assign user.');
|
||||||
}
|
}
|
||||||
}} className="p-6 space-y-5">
|
}} className="p-6 space-y-5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@@ -18,6 +18,41 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { X, Info } from 'lucide-react';
|
import { X, Info } from 'lucide-react';
|
||||||
|
import { Card as AxCard } from '@astryxdesign/core/Card';
|
||||||
|
import { Skeleton as AxSkeleton } from '@astryxdesign/core/Skeleton';
|
||||||
|
import { Tooltip as AxTooltip } from '@astryxdesign/core/Tooltip';
|
||||||
|
import { StatusDot } from '@astryxdesign/core/StatusDot';
|
||||||
|
import {
|
||||||
|
Table as AxTable,
|
||||||
|
TableHeader,
|
||||||
|
TableHeaderCell,
|
||||||
|
TableRow,
|
||||||
|
TableBody,
|
||||||
|
} from '@astryxdesign/core/Table';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* PHASE 3 — Astryx adapters.
|
||||||
|
*
|
||||||
|
* Every export below keeps its original name and signature, so the ten files
|
||||||
|
* that import from here were not touched. What changed is what each one renders
|
||||||
|
* underneath.
|
||||||
|
*
|
||||||
|
* SEVEN of the fourteen primitives now sit on Astryx: Card, FilterBar,
|
||||||
|
* TableShell, Skeleton, Tooltip, LiveStatus and SlideDrawer. Each was a
|
||||||
|
* lossless swap.
|
||||||
|
*
|
||||||
|
* THE OTHER SEVEN DELIBERATELY DID NOT MOVE, and the reason is the same for
|
||||||
|
* most of them: `Pill`, `StatusChip` and `MetricPill` take an arbitrary hex via
|
||||||
|
* a `color` prop, fed from ORDER_STATUS / DELIVERY_STATUS. Astryx's Badge takes
|
||||||
|
* a named variant from a fixed set of fourteen. Mapping onto it is lossy in a
|
||||||
|
* way that matters — both `accepted` (#6366f1) and `picked` (#8b5cf6) collapse
|
||||||
|
* to `purple`, so two distinct states on the delivery board would become
|
||||||
|
* indistinguishable. That is a functional regression, not a cosmetic one, so
|
||||||
|
* these keep rendering their own markup until the status maps themselves are
|
||||||
|
* reworked onto semantic names. GradientHeader (brand gradient), KpiStrip
|
||||||
|
* (bespoke layout), StampCell and SearchPill are unchanged for want of a
|
||||||
|
* genuine equivalent.
|
||||||
|
*/
|
||||||
|
|
||||||
// ── Design tokens ────────────────────────────────────────────────────────────────
|
// ── Design tokens ────────────────────────────────────────────────────────────────
|
||||||
export const BRAND = '#662582';
|
export const BRAND = '#662582';
|
||||||
@@ -114,13 +149,13 @@ function BrandMark() {
|
|||||||
|
|
||||||
/** Live / loading / error status line used under the header title. */
|
/** Live / loading / error status line used under the header title. */
|
||||||
export function LiveStatus({ state, label }: { state: 'live' | 'loading' | 'error'; label: string }) {
|
export function LiveStatus({ state, label }: { state: 'live' | 'loading' | 'error'; label: string }) {
|
||||||
const color = state === 'error' ? '#ef4444' : state === 'loading' ? '#94a3b8' : '#10b981';
|
// The three states map exactly onto three Astryx variants, and `isPulsing`
|
||||||
|
// replaces the hand-rolled animate-pulse. Semantic tokens mean these now
|
||||||
|
// track the theme instead of three hardcoded hexes.
|
||||||
|
const variant = state === 'error' ? 'error' : state === 'loading' ? 'neutral' : 'success';
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold" style={{ color: TEXT_2 }}>
|
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold" style={{ color: TEXT_2 }}>
|
||||||
<span
|
<StatusDot variant={variant} label={label} isPulsing={state === 'loading'} />
|
||||||
className={`rounded-full ${state === 'loading' ? 'animate-pulse' : ''}`}
|
|
||||||
style={{ width: 8, height: 8, background: color, boxShadow: state === 'live' ? `0 0 0 4px ${color}2e` : undefined }}
|
|
||||||
/>
|
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -153,7 +188,9 @@ export function KpiStrip({ items, loading }: { items: KpiItem[]; loading?: boole
|
|||||||
className="p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0"
|
className="p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0"
|
||||||
style={{ background: soft(it.color), color: it.color }}
|
style={{ background: soft(it.color), color: it.color }}
|
||||||
>
|
>
|
||||||
{React.cloneElement(it.icon as React.ReactElement, { className: 'w-3.5 h-3.5' })}
|
{/* The element's props have to be named for cloneElement to accept
|
||||||
|
className — a bare ReactElement is typed as Partial<unknown>. */}
|
||||||
|
{React.cloneElement(it.icon as React.ReactElement<{ className?: string }>, { className: 'w-3.5 h-3.5' })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 w-full">
|
<div className="mt-2 w-full">
|
||||||
@@ -276,21 +313,20 @@ export function SearchPill({ value, onChange, placeholder, color = BRAND, inputR
|
|||||||
// ── Card shells & table head cell ────────────────────────────────────────────────
|
// ── Card shells & table head cell ────────────────────────────────────────────────
|
||||||
export function Card({ children, className = '', flush }: { children: React.ReactNode; className?: string; flush?: 'top' }) {
|
export function Card({ children, className = '', flush }: { children: React.ReactNode; className?: string; flush?: 'top' }) {
|
||||||
return (
|
return (
|
||||||
<div
|
// padding={0} because every caller already supplies its own padding
|
||||||
className={`bg-white border ${flush === 'top' ? 'rounded-b-2xl' : 'rounded-2xl'} ${className}`}
|
// classes; letting Astryx add its default would shift ten screens at once.
|
||||||
style={{ borderColor: BORDER }}
|
<AxCard padding={0} className={`${flush === 'top' ? 'rounded-b-2xl rounded-t-none' : ''} ${className}`}>
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</AxCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filter/tab bar paper that visually joins the table below it (flat bottom). */
|
/** Filter/tab bar paper that visually joins the table below it (flat bottom). */
|
||||||
export function FilterBar({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
export function FilterBar({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={`bg-white border rounded-2xl p-3 sm:p-4 ${className}`} style={{ borderColor: BORDER, boxShadow: SHADOW_MD }}>
|
<AxCard padding={0} elevation="low" className={`p-3 sm:p-4 ${className}`}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</AxCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,24 +342,31 @@ export const TH_STYLE: React.CSSProperties = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function TableShell({ head, children, minWidth, footer }: { head: string[]; children: React.ReactNode; minWidth?: number; footer?: React.ReactNode }) {
|
export function TableShell({ head, children, minWidth, footer }: { head: string[]; children: React.ReactNode; minWidth?: number; footer?: React.ReactNode }) {
|
||||||
|
// `children` is a stream of caller-authored <tr>/<td>, so the body stays a
|
||||||
|
// plain TableBody passthrough — swapping every row in ten screens is Phase 6's
|
||||||
|
// job, not this one. Head cells move to Astryx now because they are owned
|
||||||
|
// here and nobody outside supplies them.
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border overflow-hidden shadow-sm flex flex-col" style={{ borderColor: BORDER }}>
|
<AxCard padding={0} className="overflow-hidden flex flex-col">
|
||||||
<div className="overflow-x-auto flex-1">
|
<div className="overflow-x-auto flex-1">
|
||||||
<table className="w-full text-sm text-left whitespace-nowrap" style={{ minWidth }}>
|
<AxTable style={{ minWidth }}>
|
||||||
<thead className="sticky top-0 z-10" style={TH_STYLE}>
|
<TableHeader>
|
||||||
<tr>
|
<TableRow>
|
||||||
{head.map((h, i) => (
|
{head.map((h, i) => (
|
||||||
<th key={i} className={`px-4 py-3 ${i > 1 ? 'text-right' : ''}`}>{h}</th>
|
// TableHeaderCell has no `align` prop, so the right-alignment
|
||||||
|
// from column three onwards is kept on an inner span rather
|
||||||
|
// than lost.
|
||||||
|
<TableHeaderCell key={i}>
|
||||||
|
<span className={i > 1 ? 'block text-right' : 'block'}>{h}</span>
|
||||||
|
</TableHeaderCell>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</TableRow>
|
||||||
</thead>
|
</TableHeader>
|
||||||
<tbody className="divide-y" style={{ borderColor: DIVIDER }}>
|
<TableBody>{children}</TableBody>
|
||||||
{children}
|
</AxTable>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</AxCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,11 +377,13 @@ export function TableShell({ head, children, minWidth, footer }: { head: string[
|
|||||||
* Uses a pulsing animation to match shape before data arrives.
|
* Uses a pulsing animation to match shape before data arrives.
|
||||||
*/
|
*/
|
||||||
export function Skeleton({ className = '', style }: { className?: string; style?: React.CSSProperties }) {
|
export function Skeleton({ className = '', style }: { className?: string; style?: React.CSSProperties }) {
|
||||||
|
// Callers size this with Tailwind classes (`h-4 w-20`), while Astryx's
|
||||||
|
// Skeleton wants numeric width/height. The wrapper keeps the caller's sizing
|
||||||
|
// and lets the Astryx one fill it, so no call site had to change.
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={className} style={style}>
|
||||||
className={`animate-pulse bg-slate-200/60 rounded-md ${className}`}
|
<AxSkeleton width="100%" height="100%" />
|
||||||
style={style}
|
</div>
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,16 +392,14 @@ export function Skeleton({ className = '', style }: { className?: string; style?
|
|||||||
* Wraps children and shows a sleek dark explanation box on hover.
|
* Wraps children and shows a sleek dark explanation box on hover.
|
||||||
*/
|
*/
|
||||||
export function Tooltip({ children, content, position = 'top' }: { children: React.ReactNode; content: React.ReactNode; position?: 'top' | 'bottom' }) {
|
export function Tooltip({ children, content, position = 'top' }: { children: React.ReactNode; content: React.ReactNode; position?: 'top' | 'bottom' }) {
|
||||||
|
// Straight swap. Astryx's version also opens on keyboard focus, which the
|
||||||
|
// hover-only original never did — a real accessibility gain for free.
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center group cursor-help w-max">
|
// Astryx names placements by flow direction, not by physical edge — so
|
||||||
{children}
|
// top/bottom become above/below. The public prop keeps its old names.
|
||||||
<div className={`absolute ${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'} left-1/2 -translate-x-1/2 hidden group-hover:block w-max max-w-[250px] z-[100] animate-in fade-in zoom-in-95 duration-200`}>
|
<AxTooltip content={content} placement={position === 'top' ? 'above' : 'below'}>
|
||||||
<div className="bg-[#0f172a] text-white text-[10px] font-medium p-2.5 rounded-xl shadow-xl border border-white/10 text-center leading-relaxed">
|
<span className="inline-flex items-center cursor-help w-max">{children}</span>
|
||||||
{content}
|
</AxTooltip>
|
||||||
</div>
|
|
||||||
<div className={`absolute ${position === 'top' ? 'top-full border-t-[#0f172a] border-x-transparent border-b-transparent' : 'bottom-full border-b-[#0f172a] border-x-transparent border-t-transparent'} left-1/2 -translate-x-1/2 border-[5px]`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,13 +419,30 @@ export function SlideDrawer({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
// Prevent body scroll when open
|
// Kept rather than delegated to Astryx's Dialog. Dialog is a centred modal
|
||||||
|
// built on <dialog>; this is an edge-anchored drawer, and forcing one into
|
||||||
|
// the other would change the shape of six screens in a phase whose whole
|
||||||
|
// premise is that call sites do not change. What it does gain is Dialog's
|
||||||
|
// two behaviours that were missing here: Escape closes it, and focus is
|
||||||
|
// restored to whatever opened it. Phase 5 is where the overlay family gets
|
||||||
|
// unified properly.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) document.body.style.overflow = 'hidden';
|
if (isOpen) document.body.style.overflow = 'hidden';
|
||||||
else document.body.style.overflow = '';
|
else document.body.style.overflow = '';
|
||||||
return () => { document.body.style.overflow = ''; };
|
return () => { document.body.style.overflow = ''; };
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const opener = document.activeElement as HTMLElement | null;
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
opener?.focus?.();
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
121
src/components/spike/ConsoleUiProbe.tsx
Normal file
121
src/components/spike/ConsoleUiProbe.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* PHASE 3 verification harness — THROWAWAY, delete with the spike folder.
|
||||||
|
*
|
||||||
|
* The ten screens that consume consoleUi sit behind a login, so this renders
|
||||||
|
* the seven rewritten primitives directly with stand-in data. It imports them
|
||||||
|
* by their original names through the original module path, which is the point:
|
||||||
|
* if these render, the adapters are signature-compatible.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
FilterBar,
|
||||||
|
TableShell,
|
||||||
|
Skeleton,
|
||||||
|
Tooltip,
|
||||||
|
LiveStatus,
|
||||||
|
SlideDrawer,
|
||||||
|
Pill,
|
||||||
|
StatusChip,
|
||||||
|
MetricPill,
|
||||||
|
StampCell,
|
||||||
|
SearchPill,
|
||||||
|
KpiStrip,
|
||||||
|
ORDER_STATUS,
|
||||||
|
DELIVERY_STATUS,
|
||||||
|
statusColor,
|
||||||
|
BRAND,
|
||||||
|
} from '../consoleUi';
|
||||||
|
import { Package } from 'lucide-react';
|
||||||
|
import { ConfirmModal } from '../ConfirmModal';
|
||||||
|
import { useNotify } from '../Toasts';
|
||||||
|
|
||||||
|
export default function ConsoleUiProbe() {
|
||||||
|
const { notifyOk, notifyFail } = useNotify();
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
const [drawer, setDrawer] = useState(false);
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [tab, setTab] = useState('pending');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<h2 className="text-xl font-extrabold">consoleUi — Phase 3 adapters</h2>
|
||||||
|
|
||||||
|
<KpiStrip
|
||||||
|
items={[
|
||||||
|
{ label: 'Orders', value: '1,248', color: BRAND, icon: <Package /> },
|
||||||
|
{ label: 'Delivered', value: '1,102', color: '#10b981', icon: <Package />, badge: '88%' },
|
||||||
|
{ label: 'Pending', value: '94', color: '#f59e0b', icon: <Package /> },
|
||||||
|
{ label: 'Cancelled', value: '52', color: '#ef4444', icon: <Package /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterBar>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<LiveStatus state="live" label="Live" />
|
||||||
|
<LiveStatus state="loading" label="Syncing" />
|
||||||
|
<LiveStatus state="error" label="Failed" />
|
||||||
|
<div className="w-64"><SearchPill value={q} onChange={setQ} placeholder="Search orders…" /></div>
|
||||||
|
{['pending', 'confirmed', 'delivered', 'cancelled'].map((s) => (
|
||||||
|
<Pill key={s} active={tab === s} color={statusColor(ORDER_STATUS, s)} onClick={() => setTab(s)} count={12}>
|
||||||
|
{s}
|
||||||
|
</Pill>
|
||||||
|
))}
|
||||||
|
<Tooltip content="Astryx tooltip — now opens on keyboard focus too.">
|
||||||
|
<span className="text-xs font-bold underline decoration-dotted">hover me</span>
|
||||||
|
</Tooltip>
|
||||||
|
<button className="text-xs font-bold underline" onClick={() => setDrawer(true)}>open drawer</button>
|
||||||
|
</div>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
<TableShell head={['Order', 'Status', 'Distance', 'Amount', 'Placed']} minWidth={720}>
|
||||||
|
{[
|
||||||
|
{ id: '#1043', s: 'delivered', km: '4.2', amt: '₹248' },
|
||||||
|
{ id: '#1041', s: 'cancelled', km: '1.1', amt: '₹89' },
|
||||||
|
{ id: '#1040', s: 'pending', km: '7.8', amt: '₹156' },
|
||||||
|
].map((r) => (
|
||||||
|
<tr key={r.id}>
|
||||||
|
<td className="px-4 py-3 font-bold">{r.id}</td>
|
||||||
|
<td className="px-4 py-3"><StatusChip label={r.s} color={statusColor(ORDER_STATUS, r.s)} /></td>
|
||||||
|
<td className="px-4 py-3 text-right"><MetricPill color={statusColor(DELIVERY_STATUS, 'active')}>{r.km} km</MetricPill></td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono font-bold">{r.amt}</td>
|
||||||
|
<td className="px-4 py-3 text-right"><StampCell date="12 Aug" time="10:42" /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TableShell>
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="text-xs font-bold mb-2">Skeletons</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="text-xs font-bold mb-2">Phase 5 — overlays & toasts</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button className="text-xs font-bold underline" onClick={() => setConfirm(true)}>open confirm dialog</button>
|
||||||
|
<button className="text-xs font-bold underline" onClick={() => notifyOk('Published to all outlets')}>toast: ok</button>
|
||||||
|
<button className="text-xs font-bold underline" onClick={() => notifyFail(new Error('Set a price before publishing.'))}>toast: error</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={confirm}
|
||||||
|
title="Unpublish product"
|
||||||
|
message="This removes it from every outlet under the tenant. Stock and history are kept."
|
||||||
|
confirmText="Unpublish"
|
||||||
|
isDestructive
|
||||||
|
onConfirm={() => { setConfirm(false); notifyOk('Unpublished from 3 outlets'); }}
|
||||||
|
onCancel={() => setConfirm(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SlideDrawer isOpen={drawer} onClose={() => setDrawer(false)} title="Order #1043">
|
||||||
|
<p className="text-sm">Escape should close this, and focus should return to the button that opened it.</p>
|
||||||
|
</SlideDrawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
src/components/spike/PosStaffPanelAstryx.tsx
Normal file
207
src/components/spike/PosStaffPanelAstryx.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* PHASE 1 SPIKE — THROWAWAY. Delete this whole folder to revert.
|
||||||
|
*
|
||||||
|
* A rebuild of the create-staff form and roster table from PosStaffPanel.tsx
|
||||||
|
* on Astryx, to answer three questions before committing to the migration:
|
||||||
|
*
|
||||||
|
* 1. Does the cascade hold? Specifically, do our unlayered `.max-w-*`
|
||||||
|
* overrides in index.css bleed into Astryx components — they beat every
|
||||||
|
* cascade layer, including `astryx-base`.
|
||||||
|
* 2. What does it cost in bundle size?
|
||||||
|
* 3. Do Field / TextInput / Selector actually cover our validation patterns?
|
||||||
|
*
|
||||||
|
* The validation rules below are copied verbatim in behaviour from
|
||||||
|
* PosStaffPanel.submit() — same order, same messages, same PIN and phone
|
||||||
|
* normalisation — so the comparison is like-for-like. This component is wired
|
||||||
|
* to no mutation: it is a rendering and typing probe, not a working screen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
|
import { Banner } from '@astryxdesign/core/Banner';
|
||||||
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
|
import { Heading } from '@astryxdesign/core/Heading';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableHeaderCell,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableBody,
|
||||||
|
} from '@astryxdesign/core/Table';
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
pos_user_id: number;
|
||||||
|
full_name: string;
|
||||||
|
role: string;
|
||||||
|
pin: string;
|
||||||
|
contactno: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stand-in roster. The real panel reads this from usePosStaff(). */
|
||||||
|
const SAMPLE: Row[] = [
|
||||||
|
{ pos_user_id: 1, full_name: 'Anitha R', role: 'Supervisor', pin: '4417', contactno: '9840012345', status: 'active' },
|
||||||
|
{ pos_user_id: 2, full_name: 'Karthik S', role: 'Cashier', pin: '2280', contactno: '9840067890', status: 'active' },
|
||||||
|
{ pos_user_id: 3, full_name: 'Meena V', role: 'Cashier', pin: '9032', contactno: '9840055512', status: 'inactive' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = [
|
||||||
|
{ value: 'Supervisor', label: 'Supervisor' },
|
||||||
|
{ value: 'Cashier', label: 'Cashier' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Per-field errors — the shape Astryx's `status` prop wants. */
|
||||||
|
type Errs = Partial<Record<'name' | 'role' | 'pin' | 'phone', string>>;
|
||||||
|
|
||||||
|
export default function PosStaffPanelAstryx() {
|
||||||
|
const [fullName, setFullName] = useState('');
|
||||||
|
const [role, setRole] = useState('');
|
||||||
|
const [pin, setPin] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [errs, setErrs] = useState<Errs>({});
|
||||||
|
const [banner, setBanner] = useState('');
|
||||||
|
|
||||||
|
const staff = SAMPLE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same rules and same wording as PosStaffPanel.submit(), but each failure is
|
||||||
|
* attached to the field that caused it instead of being funnelled into one
|
||||||
|
* `formError` string above the form. That is the substantive difference the
|
||||||
|
* spike is measuring: today the second error only appears after the first is
|
||||||
|
* fixed, because there is one slot to show it in.
|
||||||
|
*/
|
||||||
|
const submit = () => {
|
||||||
|
const next: Errs = {};
|
||||||
|
const name = fullName.trim();
|
||||||
|
|
||||||
|
if (!name) next.name = 'Enter the person’s name.';
|
||||||
|
if (!role) next.role = 'Choose a role.';
|
||||||
|
if (!/^\d{4}$/.test(pin)) {
|
||||||
|
next.pin = 'The PIN must be exactly 4 digits.';
|
||||||
|
} else if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) {
|
||||||
|
next.pin = 'Another active person at this outlet already uses that PIN.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const digits = phone.replace(/\D/g, '').replace(/^91(?=\d{10}$)/, '').replace(/^0(?=\d{10}$)/, '');
|
||||||
|
if (digits.length !== 10) {
|
||||||
|
next.phone = 'Enter a 10-digit mobile number.';
|
||||||
|
} else if (staff.some((s) => (s.contactno || '') === digits && (s.status || '').toLowerCase() !== 'inactive')) {
|
||||||
|
next.phone = 'Another active person here already signs in with that number.';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrs(next);
|
||||||
|
setBanner(Object.keys(next).length ? '' : `Would create ${name} as ${role}. (Spike — no mutation is wired.)`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
|
<div>
|
||||||
|
<Heading level={2}>POS staff — Astryx spike</Heading>
|
||||||
|
<Text>
|
||||||
|
Throwaway rebuild of the create-staff form and roster. Compare against the live panel
|
||||||
|
at Settings › POS staff.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Banner takes `title`, not children — one of several components here
|
||||||
|
that are label-prop driven rather than composition driven. */}
|
||||||
|
{banner && <Banner status="success" title={banner} />}
|
||||||
|
|
||||||
|
{/* Q1 probe: max-w-sm is one of the five utilities our unlayered repair
|
||||||
|
restates. If the repair bleeds past the layer boundary it will squeeze
|
||||||
|
this card, exactly as it squeezed the modals before the repair. */}
|
||||||
|
<Card className="max-w-sm">
|
||||||
|
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<Heading level={4}>Add a person</Heading>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Full name"
|
||||||
|
isRequired
|
||||||
|
value={fullName}
|
||||||
|
onChange={(v) => setFullName(v)}
|
||||||
|
placeholder="e.g. Anitha R"
|
||||||
|
status={errs.name ? { type: 'error', message: errs.name } : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Selector
|
||||||
|
label="Role"
|
||||||
|
isRequired
|
||||||
|
options={ROLE_OPTIONS}
|
||||||
|
value={role}
|
||||||
|
onChange={(v) => setRole(String(v ?? ''))}
|
||||||
|
placeholder="Choose a role"
|
||||||
|
status={errs.role ? { type: 'error', message: errs.role } : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="PIN"
|
||||||
|
isRequired
|
||||||
|
value={pin}
|
||||||
|
onChange={(v) => setPin(v)}
|
||||||
|
placeholder="4 digits"
|
||||||
|
description="Used to sign in at the till."
|
||||||
|
status={errs.pin ? { type: 'error', message: errs.pin } : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Mobile number"
|
||||||
|
isRequired
|
||||||
|
value={phone}
|
||||||
|
onChange={(v) => setPhone(v)}
|
||||||
|
placeholder="10 digits"
|
||||||
|
status={errs.phone ? { type: 'error', message: errs.phone } : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button label="Create" onClick={submit} />
|
||||||
|
<Button
|
||||||
|
label="Clear"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setFullName('');
|
||||||
|
setRole('');
|
||||||
|
setPin('');
|
||||||
|
setPhone('');
|
||||||
|
setErrs({});
|
||||||
|
setBanner('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Heading level={4}>Roster</Heading>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHeaderCell>Name</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Role</TableHeaderCell>
|
||||||
|
<TableHeaderCell>PIN</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Mobile</TableHeaderCell>
|
||||||
|
<TableHeaderCell>Status</TableHeaderCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{staff.map((s) => (
|
||||||
|
<TableRow key={s.pos_user_id}>
|
||||||
|
<TableCell>{s.full_name}</TableCell>
|
||||||
|
<TableCell>{s.role}</TableCell>
|
||||||
|
<TableCell>{s.pin}</TableCell>
|
||||||
|
<TableCell>{s.contactno}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge label={s.status === 'inactive' ? 'Inactive' : 'Active'} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1273
src/components/spike/ThemeShowcase.tsx
Normal file
1273
src/components/spike/ThemeShowcase.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,30 @@
|
|||||||
|
/* ── PHASE 1 SPIKE — Astryx ─────────────────────────────────────────────────
|
||||||
|
Experimental. Revert by restoring `@import "tailwindcss";` in place of the
|
||||||
|
block below and dropping the three @astryxdesign/@stylexjs dependencies.
|
||||||
|
|
||||||
|
The layer order and the split of `@import "tailwindcss"` into its three
|
||||||
|
parts are both prescribed verbatim by the Usage block at the top of
|
||||||
|
@astryxdesign/core/src/tailwind-theme.css — this is not our invention. The
|
||||||
|
ordering matters: `utilities` sits last, so Tailwind utilities keep winning
|
||||||
|
over `astryx-base`, which is what makes a half-migrated file behave.
|
||||||
|
|
||||||
|
Note `astryx.css` loads BEFORE the theme package: the theme supplies the
|
||||||
|
token values that the component CSS references. */
|
||||||
|
@layer reset, theme, base, astryx-base, astryx-theme, components, utilities;
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||||
@import "tailwindcss";
|
|
||||||
|
@import "tailwindcss/theme.css" layer(theme);
|
||||||
|
@import "tailwindcss/preflight.css" layer(base);
|
||||||
|
@import "@astryxdesign/core/reset.css";
|
||||||
|
@import "@astryxdesign/core/astryx.css";
|
||||||
|
/* Our own theme, compiled by `astryx theme build ./src/theme/nearle.ts`.
|
||||||
|
Regenerate with that command after editing nearle.ts — nearle.css is
|
||||||
|
generated and must not be hand-edited. theme-neutral was scaffolding for
|
||||||
|
the Phase 1 spike and is no longer imported. */
|
||||||
|
@import "./theme/nearle.css";
|
||||||
|
@import "@astryxdesign/core/tailwind-theme.css";
|
||||||
|
@import "tailwindcss/utilities.css" layer(utilities);
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: "Public Sans", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Public Sans", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|||||||
32
src/main.tsx
32
src/main.tsx
@@ -34,9 +34,39 @@ const queryClient = new QueryClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { Theme } from '@astryxdesign/core';
|
||||||
|
import { ToastViewport } from '@astryxdesign/core/Toast';
|
||||||
|
import { nearleTheme } from './theme/nearle';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
{/*
|
||||||
|
Theme has to wrap the app, not sit beside it: it stamps the
|
||||||
|
`data-astryx-theme` / `data-theme` attributes that every token in
|
||||||
|
nearle.css is scoped to. Without it the stylesheet loads and applies to
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
mode is deliberately "light", NOT "system", and it must stay that way
|
||||||
|
until the migration finishes.
|
||||||
|
|
||||||
|
The theme carries a full dark palette and `mode="system"` does work — but
|
||||||
|
only Astryx-backed components respond to it. Everything still on raw
|
||||||
|
Tailwind is pinned to the light scheme by hardcoded classes: KpiStrip
|
||||||
|
renders `bg-white text-slate-900`, SearchPill sets `color: TEXT`
|
||||||
|
(#0f172a). Switching to "system" today means any user whose OS is dark
|
||||||
|
gets white cards on a dark page and a search box with black text on a
|
||||||
|
black field. Verified in the browser, not assumed.
|
||||||
|
|
||||||
|
Flip this to "system" once Phase 6 has removed the hardcoded colours.
|
||||||
|
That single word is the dark-mode launch switch.
|
||||||
|
|
||||||
|
Outermost so nothing above it renders unthemed, but inside StrictMode so
|
||||||
|
it gets the same double-render checks as everything else.
|
||||||
|
*/}
|
||||||
|
<Theme theme={nearleTheme} mode="light">
|
||||||
|
{/* One viewport for the whole app. useToast() anywhere below this renders
|
||||||
|
into it — see components/Toasts.tsx. */}
|
||||||
|
<ToastViewport position="topEnd">
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<CompareProvider>
|
<CompareProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@@ -44,5 +74,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</CompareProvider>
|
</CompareProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</ToastViewport>
|
||||||
|
</Theme>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ export function buildStoreQrPayload(opts: { tenantid: number; locationid: number
|
|||||||
return JSON.stringify({ tenantid: opts.tenantid, locationid: opts.locationid });
|
return JSON.stringify({ tenantid: opts.tenantid, locationid: opts.locationid });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fiestaGet<T = unknown>(endpoint: string, params: QueryParams = {}): Promise<T> {
|
// Exported so ad-hoc probes (test-api.ts) can hit an endpoint that has no
|
||||||
|
// wrapper yet. Every function below is the supported way in; reach for this
|
||||||
|
// only when checking something against the live API.
|
||||||
|
export async function fiestaGet<T = unknown>(endpoint: string, params: QueryParams = {}): Promise<T> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
Object.entries(params).forEach(([k, v]) => {
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
// Fiesta requires some params to be present-but-empty (e.g. keyword=), so we
|
// Fiesta requires some params to be present-but-empty (e.g. keyword=), so we
|
||||||
|
|||||||
132
src/theme/nearle.css
Normal file
132
src/theme/nearle.css
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/*
|
||||||
|
* @generated by `astryx theme build` — do not edit manually.
|
||||||
|
* Source: src\theme\nearle.ts
|
||||||
|
* Command: astryx theme build src\theme\nearle.ts
|
||||||
|
* CLI: @astryxdesign/cli@0.3.0
|
||||||
|
* Core: @astryxdesign/core@0.3.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
@layer reset {
|
||||||
|
@scope ([data-astryx-theme="nearle"]) to ([data-astryx-theme]) {
|
||||||
|
:where(h1, h2, h3, h4, h5, h6) {
|
||||||
|
font-family: var(--font-family-heading);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(h1) {
|
||||||
|
font-size: var(--text-heading-1-size);
|
||||||
|
font-weight: var(--text-heading-1-weight);
|
||||||
|
line-height: var(--text-heading-1-leading);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(h2) {
|
||||||
|
font-size: var(--text-heading-2-size);
|
||||||
|
font-weight: var(--text-heading-2-weight);
|
||||||
|
line-height: var(--text-heading-2-leading);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(h3) {
|
||||||
|
font-size: var(--text-heading-3-size);
|
||||||
|
font-weight: var(--text-heading-3-weight);
|
||||||
|
line-height: var(--text-heading-3-leading);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(h4) {
|
||||||
|
font-size: var(--text-heading-4-size);
|
||||||
|
font-weight: var(--text-heading-4-weight);
|
||||||
|
line-height: var(--text-heading-4-leading);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(h5) {
|
||||||
|
font-size: var(--text-heading-5-size);
|
||||||
|
font-weight: var(--text-heading-5-weight);
|
||||||
|
line-height: var(--text-heading-5-leading);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(h6) {
|
||||||
|
font-size: var(--text-heading-6-size);
|
||||||
|
font-weight: var(--text-heading-6-weight);
|
||||||
|
line-height: var(--text-heading-6-leading);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(p) {
|
||||||
|
font-family: var(--font-family-body);
|
||||||
|
font-size: var(--text-body-size);
|
||||||
|
font-weight: var(--text-body-weight);
|
||||||
|
line-height: var(--text-body-leading);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(small) {
|
||||||
|
font-size: var(--text-supporting-size);
|
||||||
|
font-weight: var(--text-supporting-weight);
|
||||||
|
line-height: var(--text-supporting-leading);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(code, pre) {
|
||||||
|
font-family: var(--font-family-code);
|
||||||
|
font-size: var(--text-code-size);
|
||||||
|
line-height: var(--text-code-leading);
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(hr) {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer astryx-theme {
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
html[data-theme="light"] { color-scheme: light; }
|
||||||
|
html[data-theme="dark"] { color-scheme: dark; }
|
||||||
|
|
||||||
|
@scope ([data-astryx-theme="nearle"]) to ([data-astryx-theme]) {
|
||||||
|
:scope {
|
||||||
|
--font-family-body: "Public Sans", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-family-heading: "Public Sans", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-family-code: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||||
|
--color-accent: light-dark(#662582, #B57FD0);
|
||||||
|
--color-on-accent: light-dark(#FFFFFF, #1B1420);
|
||||||
|
--color-accent-muted: light-dark(#66258218, #B57FD024);
|
||||||
|
--color-text-accent: light-dark(#662582, #B57FD0);
|
||||||
|
--color-icon-accent: light-dark(#662582, #B57FD0);
|
||||||
|
--color-text-primary: light-dark(#0f172a, #ECE6F1);
|
||||||
|
--color-text-secondary: light-dark(#64748b, #B3A8BD);
|
||||||
|
--color-text-disabled: light-dark(#94a3b8, #85798F);
|
||||||
|
--color-background-surface: light-dark(#FFFFFF, #1C1724);
|
||||||
|
--color-background-body: light-dark(#f8fafc, #14101A);
|
||||||
|
--color-background-card: light-dark(#FFFFFF, #1C1724);
|
||||||
|
--color-background-popover: light-dark(#FFFFFF, #241D2E);
|
||||||
|
--color-background-muted: light-dark(#f1f5f9, #241D2E);
|
||||||
|
--color-border: light-dark(#e2e8f0, #352C40);
|
||||||
|
--color-border-emphasized: light-dark(#cbd5e1, #4A3E58);
|
||||||
|
--color-skeleton: light-dark(#f1f5f9, #241D2E);
|
||||||
|
--color-success: light-dark(#10b981, #34D399);
|
||||||
|
--color-error: light-dark(#ef4444, #F87171);
|
||||||
|
--color-warning: light-dark(#f59e0b, #FBBF24);
|
||||||
|
--shadow-low: light-dark(0 8px 24px rgba(15, 23, 42, 0.08), 0 8px 24px rgba(0, 0, 0, 0.45));
|
||||||
|
--shadow-med: light-dark(0 14px 40px rgba(15, 23, 42, 0.10), 0 14px 40px rgba(0, 0, 0, 0.55));
|
||||||
|
--shadow-high: light-dark(0 18px 50px rgba(15, 23, 42, 0.18), 0 18px 50px rgba(0, 0, 0, 0.65));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer astryx-theme {
|
||||||
|
@scope ([data-astryx-theme="nearle"]) to ([data-astryx-theme]) {
|
||||||
|
[data-astryx-media="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--color-text-primary: var(--color-on-dark);
|
||||||
|
--color-icon-primary: var(--color-on-dark);
|
||||||
|
--color-accent: var(--color-on-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-astryx-media="light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--color-text-primary: var(--color-on-light);
|
||||||
|
--color-icon-primary: var(--color-on-light);
|
||||||
|
--color-accent: var(--color-on-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/theme/nearle.d.ts
vendored
Normal file
10
src/theme/nearle.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* @generated by `astryx theme build` — do not edit manually.
|
||||||
|
* Source: src\theme\nearle.ts
|
||||||
|
* Command: astryx theme build src\theme\nearle.ts
|
||||||
|
* CLI: @astryxdesign/cli@0.3.0
|
||||||
|
* Core: @astryxdesign/core@0.3.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DefinedTheme } from '@astryxdesign/core/theme';
|
||||||
|
export declare const nearleTheme: DefinedTheme;
|
||||||
47
src/theme/nearle.js
Normal file
47
src/theme/nearle.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* @generated by `astryx theme build` — do not edit manually.
|
||||||
|
* Source: src\theme\nearle.ts
|
||||||
|
* Command: astryx theme build src\theme\nearle.ts
|
||||||
|
* CLI: @astryxdesign/cli@0.3.0
|
||||||
|
* Core: @astryxdesign/core@0.3.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* nearle theme — built by `npx astryx theme build`
|
||||||
|
* Import the CSS file alongside this module:
|
||||||
|
*
|
||||||
|
* import { nearleTheme } from './nearle';
|
||||||
|
* import './nearle.css';
|
||||||
|
*/
|
||||||
|
export const nearleTheme = {
|
||||||
|
name: 'nearle',
|
||||||
|
__built: true,
|
||||||
|
tokens: {
|
||||||
|
"--font-family-body": "\"Public Sans\", ui-sans-serif, system-ui, sans-serif",
|
||||||
|
"--font-family-heading": "\"Public Sans\", ui-sans-serif, system-ui, sans-serif",
|
||||||
|
"--font-family-code": "\"JetBrains Mono\", ui-monospace, SFMono-Regular, monospace",
|
||||||
|
"--color-accent": "light-dark(#662582, #B57FD0)",
|
||||||
|
"--color-on-accent": "light-dark(#FFFFFF, #1B1420)",
|
||||||
|
"--color-accent-muted": "light-dark(#66258218, #B57FD024)",
|
||||||
|
"--color-text-accent": "light-dark(#662582, #B57FD0)",
|
||||||
|
"--color-icon-accent": "light-dark(#662582, #B57FD0)",
|
||||||
|
"--color-text-primary": "light-dark(#0f172a, #ECE6F1)",
|
||||||
|
"--color-text-secondary": "light-dark(#64748b, #B3A8BD)",
|
||||||
|
"--color-text-disabled": "light-dark(#94a3b8, #85798F)",
|
||||||
|
"--color-background-surface": "light-dark(#FFFFFF, #1C1724)",
|
||||||
|
"--color-background-body": "light-dark(#f8fafc, #14101A)",
|
||||||
|
"--color-background-card": "light-dark(#FFFFFF, #1C1724)",
|
||||||
|
"--color-background-popover": "light-dark(#FFFFFF, #241D2E)",
|
||||||
|
"--color-background-muted": "light-dark(#f1f5f9, #241D2E)",
|
||||||
|
"--color-border": "light-dark(#e2e8f0, #352C40)",
|
||||||
|
"--color-border-emphasized": "light-dark(#cbd5e1, #4A3E58)",
|
||||||
|
"--color-skeleton": "light-dark(#f1f5f9, #241D2E)",
|
||||||
|
"--color-success": "light-dark(#10b981, #34D399)",
|
||||||
|
"--color-error": "light-dark(#ef4444, #F87171)",
|
||||||
|
"--color-warning": "light-dark(#f59e0b, #FBBF24)",
|
||||||
|
"--shadow-low": "light-dark(0 8px 24px rgba(15, 23, 42, 0.08), 0 8px 24px rgba(0, 0, 0, 0.45))",
|
||||||
|
"--shadow-med": "light-dark(0 14px 40px rgba(15, 23, 42, 0.10), 0 14px 40px rgba(0, 0, 0, 0.55))",
|
||||||
|
"--shadow-high": "light-dark(0 18px 50px rgba(15, 23, 42, 0.18), 0 18px 50px rgba(0, 0, 0, 0.65))"
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
102
src/theme/nearle.ts
Normal file
102
src/theme/nearle.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* The Nearle theme for Astryx — PHASE 2.
|
||||||
|
*
|
||||||
|
* Every value here already existed in the codebase; nothing was invented for
|
||||||
|
* aesthetics. The light column is lifted verbatim from the eleven hardcoded
|
||||||
|
* constants at the top of `components/consoleUi.tsx` and from the two status
|
||||||
|
* colour maps beneath them. The dark column is new, because the console has
|
||||||
|
* never had a dark mode and there was nothing to lift.
|
||||||
|
*
|
||||||
|
* Written by hand rather than by `astryx theme`: that CLI command is an
|
||||||
|
* interactive wizard, and a generated file would still have needed every value
|
||||||
|
* below typed into it.
|
||||||
|
*
|
||||||
|
* TOKEN VALUES take one of two forms (see defineTheme's contract):
|
||||||
|
* 'string' — same in both modes
|
||||||
|
* ['light', 'dark'] — compiled to CSS light-dark()
|
||||||
|
*
|
||||||
|
* HOW MODE IS CHOSEN. `theme-neutral` ships no `prefers-color-scheme` block at
|
||||||
|
* all; Astryx switches on a `data-theme` attribute that the `<Theme>` provider
|
||||||
|
* stamps. So `mode="system"` on the provider is what connects this to the OS
|
||||||
|
* setting — the stylesheet alone will not do it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineTheme } from '@astryxdesign/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dark accent is the one value in this file that is not simply lifted, and
|
||||||
|
* it is the one that needs a human's approval.
|
||||||
|
*
|
||||||
|
* `BRAND` #662582 scores 9.76:1 on white — comfortably AA. On Astryx's dark
|
||||||
|
* surface #1F1F22 it is unreadable, and `BRAND_LIGHT` #9255AB only reaches
|
||||||
|
* **3.22:1**, which is below the 4.5:1 threshold. Measured candidates:
|
||||||
|
*
|
||||||
|
* #9255AB 3.22:1 BRAND_LIGHT as-is fails AA
|
||||||
|
* #A96FC4 4.51:1 passes by 0.01 no headroom
|
||||||
|
* #B57FD0 5.41:1 passes with room ← chosen
|
||||||
|
* #C48FDD 6.51:1 passes, drifts pastel
|
||||||
|
*
|
||||||
|
* #B57FD0 is the lightest value that still reads as the Nearle purple rather
|
||||||
|
* than as lilac. **Brand owner to confirm before dark mode ships.**
|
||||||
|
*/
|
||||||
|
const BRAND_DARK = '#B57FD0';
|
||||||
|
|
||||||
|
export const nearleTheme = defineTheme({
|
||||||
|
name: 'nearle',
|
||||||
|
|
||||||
|
typography: {
|
||||||
|
// Both families are already pulled in by the Google Fonts @import at the
|
||||||
|
// top of index.css, so this re-points Astryx at them rather than adding a
|
||||||
|
// request. Astryx does not load fonts itself — the consumer must.
|
||||||
|
body: { family: 'Public Sans', fallbacks: 'ui-sans-serif, system-ui, sans-serif' },
|
||||||
|
heading: { family: 'Public Sans', fallbacks: 'ui-sans-serif, system-ui, sans-serif' },
|
||||||
|
code: { family: 'JetBrains Mono', fallbacks: 'ui-monospace, SFMono-Regular, monospace' },
|
||||||
|
},
|
||||||
|
|
||||||
|
tokens: {
|
||||||
|
// ── Brand ───────────────────────────────────────────────────────────────
|
||||||
|
'--color-accent': ['#662582', BRAND_DARK],
|
||||||
|
'--color-on-accent': ['#FFFFFF', '#1B1420'],
|
||||||
|
// The alpha suffixes mirror consoleUi's `soft()` helper (18 ≈ 9%), which is
|
||||||
|
// what every tinted brand surface in the app is currently built from.
|
||||||
|
'--color-accent-muted': ['#66258218', `${BRAND_DARK}24`],
|
||||||
|
'--color-text-accent': ['#662582', BRAND_DARK],
|
||||||
|
'--color-icon-accent': ['#662582', BRAND_DARK],
|
||||||
|
|
||||||
|
// ── Text ── TEXT / TEXT_2 / TEXT_3 ──────────────────────────────────────
|
||||||
|
'--color-text-primary': ['#0f172a', '#ECE6F1'],
|
||||||
|
'--color-text-secondary': ['#64748b', '#B3A8BD'],
|
||||||
|
'--color-text-disabled': ['#94a3b8', '#85798F'],
|
||||||
|
|
||||||
|
// ── Surfaces ── SURFACE_ALT / DIVIDER ───────────────────────────────────
|
||||||
|
// The dark greys carry a slight violet bias so they sit under the accent
|
||||||
|
// as a chosen neutral rather than a stock grey.
|
||||||
|
'--color-background-surface': ['#FFFFFF', '#1C1724'],
|
||||||
|
'--color-background-body': ['#f8fafc', '#14101A'],
|
||||||
|
'--color-background-card': ['#FFFFFF', '#1C1724'],
|
||||||
|
'--color-background-popover': ['#FFFFFF', '#241D2E'],
|
||||||
|
'--color-background-muted': ['#f1f5f9', '#241D2E'],
|
||||||
|
|
||||||
|
// ── Lines ── BORDER / DIVIDER ───────────────────────────────────────────
|
||||||
|
'--color-border': ['#e2e8f0', '#352C40'],
|
||||||
|
'--color-border-emphasized': ['#cbd5e1', '#4A3E58'],
|
||||||
|
'--color-skeleton': ['#f1f5f9', '#241D2E'],
|
||||||
|
|
||||||
|
// ── Semantic ────────────────────────────────────────────────────────────
|
||||||
|
// Taken from ORDER_STATUS / DELIVERY_STATUS so a Badge and a status pill
|
||||||
|
// agree. Dark variants are lightened for the same contrast reason as the
|
||||||
|
// accent — the source values are tuned for white.
|
||||||
|
'--color-success': ['#10b981', '#34D399'],
|
||||||
|
'--color-error': ['#ef4444', '#F87171'],
|
||||||
|
'--color-warning': ['#f59e0b', '#FBBF24'],
|
||||||
|
|
||||||
|
// ── Elevation ── SHADOW_MD / SOFT / POP ─────────────────────────────────
|
||||||
|
// Dark mode gets its own shadows rather than the same rgba: a shadow tuned
|
||||||
|
// for a white ground is invisible on a dark one, so the alpha is raised.
|
||||||
|
'--shadow-low': ['0 8px 24px rgba(15, 23, 42, 0.08)', '0 8px 24px rgba(0, 0, 0, 0.45)'],
|
||||||
|
'--shadow-med': ['0 14px 40px rgba(15, 23, 42, 0.10)', '0 14px 40px rgba(0, 0, 0, 0.55)'],
|
||||||
|
'--shadow-high': ['0 18px 50px rgba(15, 23, 42, 0.18)', '0 18px 50px rgba(0, 0, 0, 0.65)'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default nearleTheme;
|
||||||
Reference in New Issue
Block a user