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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astryxdesign/core": "0.3.0",
|
||||
"@astryxdesign/theme-neutral": "0.3.0",
|
||||
"@google/genai": "^2.4.0",
|
||||
"@stylexjs/stylex": "^0.19.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
@@ -32,8 +35,11 @@
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astryxdesign/cli": "^0.3.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"esbuild": "^0.25.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
|
||||
11
src/App.tsx
11
src/App.tsx
@@ -5,6 +5,9 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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 {
|
||||
Network,
|
||||
Truck,
|
||||
@@ -560,6 +563,14 @@ export default function App() {
|
||||
if (authRole === null) {
|
||||
return (
|
||||
<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="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -33,8 +33,13 @@ import {
|
||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum } from '../services/fiestaApi';
|
||||
import StoreQRView from './StoreQRView';
|
||||
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 }) {
|
||||
// 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');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -171,7 +176,7 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
|
||||
const handleTenantSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
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;
|
||||
}
|
||||
try {
|
||||
@@ -197,16 +202,16 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
|
||||
},
|
||||
});
|
||||
setTenantSuccess(res);
|
||||
alert('Tenant Onboarded successfully!');
|
||||
notifyOk('Tenant onboarded.');
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to onboard tenant.');
|
||||
notifyFail(err, 'Failed to onboard tenant.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStoreSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!storeForm.locationname) {
|
||||
alert('Location Name is required.');
|
||||
notifyFail('Location name is required.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -221,16 +226,16 @@ export default function AdminConsole({ activeTab: propActiveTab, showHeader = tr
|
||||
roleid: 4,
|
||||
});
|
||||
setStoreSuccess(res);
|
||||
alert('Store Location created successfully!');
|
||||
notifyOk('Store location created.');
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to create store location.');
|
||||
notifyFail(err, 'Failed to create store location.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRiderSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
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;
|
||||
}
|
||||
try {
|
||||
@@ -282,9 +287,9 @@ VALUES (${newUserId}, 1, 'Active', NOW());
|
||||
userid: newUserId,
|
||||
});
|
||||
|
||||
alert('Rider User created in app_users! SQL Script generated below.');
|
||||
notifyOk('Rider user created. SQL script generated below.');
|
||||
} 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>
|
||||
{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>
|
||||
</div>
|
||||
@@ -1261,7 +1266,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
|
||||
{tenantsQ.isLoading ? (
|
||||
<option>Loading tenants...</option>
|
||||
) : (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>
|
||||
</div>
|
||||
@@ -1574,7 +1579,7 @@ VALUES (${newUserId}, 1, 'Active', NOW());
|
||||
>
|
||||
<option value={0}>Select Hub location...</option>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { ShoppingCart, X, Package, Trash2, Plus, ArrowRight, Layers } from 'lucide-react';
|
||||
import { useCompare } from '../contexts/CompareContext';
|
||||
import { useStoreCatalogue } from '../services/storeCatalogue';
|
||||
import { useNotify } from './Toasts';
|
||||
|
||||
interface BulkCartDrawerProps {
|
||||
isOpen: boolean;
|
||||
@@ -12,13 +13,14 @@ interface BulkCartDrawerProps {
|
||||
export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: BulkCartDrawerProps) {
|
||||
const { selectedProducts, removeProduct, clearSelection, setIsComparing } = useCompare();
|
||||
const storeCat = useStoreCatalogue();
|
||||
const { notifyOk } = useNotify();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleBulkAdd = () => {
|
||||
let addedCount = 0;
|
||||
selectedProducts.forEach(prod => {
|
||||
if (prod.verified && !storeCat.has(prod.id)) {
|
||||
if (prod.verified && !storeCat.has(String(prod.id))) {
|
||||
storeCat.add({
|
||||
productid: String(prod.id),
|
||||
name: prod.name,
|
||||
@@ -43,7 +45,10 @@ export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: Bu
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -55,7 +60,8 @@ export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: Bu
|
||||
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();
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ import { useStoreCatalogue, usePriceEverywhere } from '../services/storeCatalogu
|
||||
import { useFiestaTenantLocations, useFiestaProductLocations } from '../services/fiestaQueries';
|
||||
import { num as fnum } from '../services/fiestaApi';
|
||||
import ImportProductModal from './ImportProductModal';
|
||||
import { useNotify } from './Toasts';
|
||||
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 {
|
||||
tenantid: number;
|
||||
@@ -87,6 +93,9 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
|
||||
[tenantCategories],
|
||||
);
|
||||
|
||||
// PHASE 6 — replaces alert(), which blocked the whole browser until dismissed.
|
||||
const { notifyOk, notifyFail } = useNotify();
|
||||
|
||||
const handleImportSubmit = (item: ImportCatalogueProductRequest) => {
|
||||
importProductMutation.mutate([item], {
|
||||
onSuccess: () => {
|
||||
@@ -101,10 +110,14 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
|
||||
//
|
||||
// The product now lands in the Admin Catalogue alone. Pricing and
|
||||
// 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) => {
|
||||
alert(err.message || 'Failed to import product.');
|
||||
}
|
||||
onError: (err: any) => notifyFail(err, 'Failed to import product.'),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -131,21 +144,14 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{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 className="relative flex items-center justify-center shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={brand === 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} />
|
||||
</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 key={b.brand} className="flex items-center gap-2 px-1 py-0.5">
|
||||
<CheckboxInput
|
||||
label={b.brand.replace("brand_", "").toUpperCase()}
|
||||
value={brand === b.brand}
|
||||
onChange={() => setBrand(brand === b.brand ? undefined : b.brand)}
|
||||
/>
|
||||
<span className="ml-auto text-[10px] font-bold opacity-60">{b.product_count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -198,23 +204,16 @@ export default function CatalogueBrowser({ tenantid, locationid, onClose }: Cata
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Search size={14} className="text-slate-400 group-focus-within:text-purple-600 transition-colors shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search catalogue..."
|
||||
<div className="w-64">
|
||||
<TextInput
|
||||
label="Search catalogue"
|
||||
isLabelHidden
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
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"
|
||||
onChange={(v) => setKeyword(v)}
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
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 {
|
||||
isOpen: boolean;
|
||||
@@ -24,67 +45,42 @@ export function ConfirmModal({
|
||||
isDestructive = false,
|
||||
isLoading = false,
|
||||
}: ConfirmModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !isLoading) onCancel(); }}
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
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
|
||||
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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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>
|
||||
<DialogHeader
|
||||
title={title}
|
||||
startContent={isDestructive ? <AlertTriangle size={18} className="text-red-500" /> : undefined}
|
||||
onOpenChange={(open) => { if (!open && !isLoading) onCancel(); }}
|
||||
/>
|
||||
|
||||
<div className="p-5 overflow-y-auto">
|
||||
<div className="text-sm text-slate-600 leading-relaxed">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed py-2">{message}</div>
|
||||
|
||||
<div className="p-4 bg-slate-50/80 border-t border-slate-100 flex justify-end gap-3 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
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"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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 className="flex justify-end gap-3 pt-4">
|
||||
<Button label={cancelText} variant="secondary" isDisabled={isLoading} onClick={onCancel} />
|
||||
<Button
|
||||
label={confirmText}
|
||||
variant={isDestructive ? 'destructive' : 'primary'}
|
||||
isDisabled={isLoading}
|
||||
isLoading={isLoading}
|
||||
onClick={onConfirm}
|
||||
/>
|
||||
</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 FMCGHoverOverlay from './FMCGHoverOverlay';
|
||||
import CatalogueDetailPanel from './CatalogueDetailPanel';
|
||||
import { useNotify } from './Toasts';
|
||||
import CatalogueBrowser from './CatalogueBrowser';
|
||||
import OfflineSalesUpload from './OfflineSalesUpload';
|
||||
import { useCompare } from '../contexts/CompareContext';
|
||||
@@ -178,6 +179,9 @@ export default function InventoryView({
|
||||
}, [storeCat.items]);
|
||||
|
||||
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 unpublishMut = useUnpublishProduct();
|
||||
const [importPrice, setImportPrice] = useState<string>('');
|
||||
@@ -734,9 +738,13 @@ export default function InventoryView({
|
||||
taxpercent: Number(cardImportTax) || 0,
|
||||
},
|
||||
{
|
||||
onSuccess: () => setAddingPriceProdId(null),
|
||||
onError: (err: any) =>
|
||||
alert(err?.message || 'Could not publish this product.'),
|
||||
onSuccess: () => {
|
||||
setAddingPriceProdId(null);
|
||||
// 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),
|
||||
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"
|
||||
|
||||
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 { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
import { useNotify } from './Toasts';
|
||||
|
||||
interface OperationsViewProps {
|
||||
searchQuery: string;
|
||||
@@ -37,6 +38,10 @@ interface 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
|
||||
const [activeSubTab, setActiveSubTab] = useState<'inventory' | 'catalogue' | 'import'>('inventory');
|
||||
|
||||
@@ -111,7 +116,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
const handleAddSku = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -143,7 +148,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
const handleExecuteTransfer = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!transferData.sku || !transferData.origin || !transferData.destination) {
|
||||
alert('Kindly configure transfer coordinates and SKU.');
|
||||
notifyFail('Configure the transfer coordinates and SKU first.');
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -414,7 +419,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
onClick={() => {
|
||||
const amount = prompt('Enter return item SKU code:');
|
||||
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"
|
||||
@@ -425,7 +430,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
@@ -473,7 +478,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
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"
|
||||
@@ -549,7 +554,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
onClick={() => {
|
||||
const fileRef = prompt('Enter CSV filename representation path:');
|
||||
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"
|
||||
@@ -573,7 +578,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
<button
|
||||
onClick={(e) => {
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -55,7 +55,10 @@ export default function OrderDetailsModal({ order, onClose }: OrderDetailsModalP
|
||||
const deliveryFee = fnum(order.deliveryfee) || fnum(order.deliverycharge) || 0;
|
||||
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(
|
||||
<div className="odm-overlay">
|
||||
|
||||
@@ -69,6 +69,20 @@ import {
|
||||
type PosTerminal,
|
||||
} from '../services/posApi';
|
||||
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 {
|
||||
type PosOutletHealth,
|
||||
usePosCatalogue,
|
||||
@@ -320,18 +334,20 @@ export default function PosConsoleView({
|
||||
</Pill>
|
||||
</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={() => {
|
||||
healthQ.refetch();
|
||||
summaryQ.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>
|
||||
}
|
||||
/>
|
||||
@@ -380,9 +396,13 @@ export default function PosConsoleView({
|
||||
<div className="text-[13px]" style={{ color: TEXT }}>
|
||||
<strong>{pendingTotal}</strong> bill{pendingTotal === 1 ? '' : 's'} still sitting on{' '}
|
||||
{strandedTills} till{strandedTills === 1 ? '' : 's'} and not yet synced.
|
||||
<button onClick={() => setTab('terminals')} className="ml-2 underline font-semibold cursor-pointer" style={{ color: BRAND }}>
|
||||
See which
|
||||
</button>
|
||||
{/* Reads as a link mid-sentence but performs an in-page action, so
|
||||
it stays a <button> underneath — Link renders as one when given
|
||||
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>
|
||||
)}
|
||||
@@ -983,54 +1003,60 @@ function BillsTab(props: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 rounded-full px-3 py-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
||||
<Barcode size={13} style={{ color: TEXT_3 }} />
|
||||
<input
|
||||
{/* PHASE 6 — the bills filter bar.
|
||||
`items-end` rather than `items-center`: Astryx fields carry a label
|
||||
above the control, so centring would stagger them against the
|
||||
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}
|
||||
onChange={(e) => setReference(e.target.value)}
|
||||
onChange={(v) => setReference(v)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onLookup()}
|
||||
placeholder="Invoice, order UUID or bill id…"
|
||||
className="bg-transparent outline-none text-[12.5px] w-56"
|
||||
style={{ color: TEXT }}
|
||||
startIcon={<Barcode size={13} />}
|
||||
// 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>
|
||||
|
||||
<select
|
||||
value={terminalFilter}
|
||||
onChange={(e) => setTerminalFilter(e.target.value)}
|
||||
className="rounded-full px-3 py-1.5 text-[12.5px] font-semibold cursor-pointer outline-none"
|
||||
style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}`, color: TEXT }}
|
||||
>
|
||||
<option value="">All terminals</option>
|
||||
{terminalOptions.filter(Boolean).map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="w-44">
|
||||
<Selector
|
||||
label="Terminal"
|
||||
value={terminalFilter}
|
||||
onChange={(v) => setTerminalFilter(String(v ?? ''))}
|
||||
placeholder="All terminals"
|
||||
options={[
|
||||
{ value: '', label: 'All terminals' },
|
||||
...terminalOptions.filter(Boolean).map((t) => ({ value: t, label: t })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={modeFilter}
|
||||
onChange={(e) => setModeFilter(e.target.value)}
|
||||
className="rounded-full px-3 py-1.5 text-[12.5px] font-semibold cursor-pointer outline-none"
|
||||
style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}`, color: TEXT }}
|
||||
>
|
||||
<option value="">All tenders</option>
|
||||
{['cash', 'card', 'upi', 'wallet'].map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="w-40">
|
||||
<Selector
|
||||
label="Tender"
|
||||
value={modeFilter}
|
||||
onChange={(v) => setModeFilter(String(v ?? ''))}
|
||||
placeholder="All tenders"
|
||||
options={[
|
||||
{ value: '', label: 'All tenders' },
|
||||
...['cash', 'card', 'upi', 'wallet'].map((m) => ({ value: m, label: m })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 rounded-full px-3 py-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
||||
<Users size={13} style={{ color: TEXT_3 }} />
|
||||
<input
|
||||
<div className="w-48">
|
||||
<TextInput
|
||||
label="Cashier"
|
||||
value={cashierFilter}
|
||||
onChange={(e) => setCashierFilter(e.target.value)}
|
||||
placeholder="Cashier (exact name)"
|
||||
className="bg-transparent outline-none text-[12.5px] w-40"
|
||||
style={{ color: TEXT }}
|
||||
onChange={(v) => setCashierFilter(v)}
|
||||
placeholder="Exact name"
|
||||
startIcon={<Users size={13} />}
|
||||
hasClear
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1071,22 +1097,23 @@ function BillsTab(props: {
|
||||
Page {pageno + 1} of {pages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={pageno === 0}
|
||||
{/* `secondary` rather than the tinted-brand pills: these sit
|
||||
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)}
|
||||
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)}` }}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
disabled={pageno + 1 >= pages}
|
||||
/>
|
||||
<Button
|
||||
label="Next"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isDisabled={pageno + 1 >= pages}
|
||||
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>
|
||||
) : undefined
|
||||
@@ -1187,17 +1214,20 @@ function BillDetail({ bill, loading, reference }: { bill: PosBill | null; loadin
|
||||
{/* The drawer clips horizontally, so the line table scrolls inside
|
||||
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 }}>
|
||||
<table className="w-full text-[12px]" style={{ minWidth: 420 }}>
|
||||
<thead>
|
||||
<tr style={{ background: SURFACE_ALT, color: TEXT_2 }}>
|
||||
<th className="px-3 py-2 text-left font-extrabold">Item</th>
|
||||
<th className="px-3 py-2 text-right font-extrabold">Qty</th>
|
||||
<th className="px-3 py-2 text-right font-extrabold">Rate</th>
|
||||
<th className="px-3 py-2 text-right font-extrabold">GST</th>
|
||||
<th className="px-3 py-2 text-right font-extrabold">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y" style={{ borderColor: DIVIDER }}>
|
||||
{/* The bill's line items. Header cells move to Astryx; the rows
|
||||
below stay as the caller's own <tr>, so the money formatting
|
||||
and the barcode sub-line are untouched. */}
|
||||
<AxTable style={{ minWidth: 420 }}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell><span className="block">Item</span></TableHeaderCell>
|
||||
<TableHeaderCell><span className="block text-right">Qty</span></TableHeaderCell>
|
||||
<TableHeaderCell><span className="block text-right">Rate</span></TableHeaderCell>
|
||||
<TableHeaderCell><span className="block text-right">GST</span></TableHeaderCell>
|
||||
<TableHeaderCell><span className="block text-right">Total</span></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((it) => (
|
||||
<tr key={it.posorderitemid}>
|
||||
<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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</AxTable>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1460,25 +1490,27 @@ function PosTable({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
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">
|
||||
<table className="w-full text-sm whitespace-nowrap" style={{ minWidth }}>
|
||||
<thead style={TH_STYLE}>
|
||||
<tr>
|
||||
<AxTable style={{ minWidth }}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{columns.map((c, i) => (
|
||||
<th key={i} className={`px-4 py-3 ${c.align === 'right' ? 'text-right' : 'text-left'}`}>
|
||||
{c.label}
|
||||
</th>
|
||||
<TableHeaderCell key={i}>
|
||||
<span className={c.align === 'right' ? 'block text-right' : 'block'}>{c.label}</span>
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y" style={{ borderColor: DIVIDER }}>
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{children}</TableBody>
|
||||
</AxTable>
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
</AxCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
|
||||
import React from '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 {
|
||||
children: React.ReactNode;
|
||||
@@ -29,15 +33,11 @@ interface State {
|
||||
}
|
||||
|
||||
export default class PosErrorBoundary extends React.Component<Props, State> {
|
||||
// This project has no `@types/react` installed, so `React.Component`'s
|
||||
// inherited members are not typed and `this.props` / `this.setState` do not
|
||||
// resolve. (It is the same gap that makes `key` unassignable on a custom
|
||||
// component, which is why the codebase wraps keyed elements in fragments.)
|
||||
// Re-declared here rather than cast at each use — `declare` emits nothing and
|
||||
// keeps the bodies below honest. Delete once the types are added.
|
||||
declare props: Props;
|
||||
declare setState: (state: State) => void;
|
||||
|
||||
// The `declare props` / `declare setState` workaround that used to sit here
|
||||
// is gone. It existed only because the project had no `@types/react`, so
|
||||
// React.Component's inherited members did not resolve. Phase 0 installed the
|
||||
// types, so the real ones now apply and the shim was actively misleading —
|
||||
// it narrowed setState to a shape React does not actually guarantee.
|
||||
state: State = { error: null };
|
||||
|
||||
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;
|
||||
|
||||
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="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">
|
||||
<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} />
|
||||
<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">
|
||||
<AlertTriangle size={30} />
|
||||
</div>
|
||||
<Heading level={2}>The POS screen hit a problem</Heading>
|
||||
<div className="mt-3 mb-2">
|
||||
<Text>
|
||||
Counter sales are unaffected — bills keep syncing from the tills whatever this
|
||||
page does. Only the display failed.
|
||||
</Text>
|
||||
</div>
|
||||
<p className="text-[12px] font-mono break-words mb-6 opacity-60">
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
<Button label="Try again" onClick={() => this.setState({ error: null })} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 tracking-tight mb-3">
|
||||
The POS screen hit a problem
|
||||
</h1>
|
||||
<p className="text-[15px] text-slate-500 leading-relaxed mb-2">
|
||||
Counter sales are unaffected — bills keep syncing from the tills whatever this page
|
||||
does. Only the display failed.
|
||||
</p>
|
||||
<p className="text-[12px] text-slate-400 font-mono break-words mb-6">
|
||||
{this.state.error.message}
|
||||
</p>
|
||||
<button
|
||||
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>
|
||||
</AxCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
@@ -106,6 +112,19 @@ export default function PosStaffPanel({
|
||||
const [phone, setPhone] = useState('');
|
||||
const [shiftId, setShiftId] = useState(0);
|
||||
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 [copied, setCopied] = useState(false);
|
||||
const [confirmOff, setConfirmOff] = useState<PosStaffUser | null>(null);
|
||||
@@ -138,29 +157,44 @@ export default function PosStaffPanel({
|
||||
setPhone('');
|
||||
setShiftId(0);
|
||||
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 name = fullName.trim();
|
||||
const chosen = role || (roles.length === 1 ? roles[0].role : '');
|
||||
if (!name) return setFormError('Enter the person’s name.');
|
||||
if (!chosen) return setFormError('Choose a role.');
|
||||
if (!/^\d{4}$/.test(pin)) return setFormError('The PIN must be exactly 4 digits.');
|
||||
// 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
|
||||
// told apart on a bill.
|
||||
if (staff.some((s) => s.pin === pin && (s.status || '').toLowerCase() !== 'inactive')) {
|
||||
return setFormError('Another active person at this outlet already uses that PIN.');
|
||||
// Same rules, same wording, same order as before — the only change is that
|
||||
// every failure is collected and attached to its field instead of the first
|
||||
// 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
|
||||
// one counter both appear on the till's sign-in list and neither can be
|
||||
// told apart on a bill.
|
||||
errs.pin = 'Another active person at this outlet already uses that PIN.';
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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}$)/, '');
|
||||
if (digits.length !== 10) return setFormError('Enter a 10-digit mobile number.');
|
||||
if (staff.some((s) => (s.contactno || '') === digits && (s.status || '').toLowerCase() !== 'inactive')) {
|
||||
return setFormError('Another active person here already signs in with that number.');
|
||||
if (digits.length !== 10) {
|
||||
errs.phone = 'Enter a 10-digit mobile 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('');
|
||||
createMut.mutate(
|
||||
{
|
||||
@@ -302,87 +336,87 @@ export default function PosStaffPanel({
|
||||
|
||||
{showForm && (
|
||||
<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">
|
||||
<input
|
||||
<TextInput
|
||||
label="Full name"
|
||||
isRequired
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
placeholder="Full name"
|
||||
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
|
||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
||||
onChange={(v) => setFullName(v)}
|
||||
placeholder="e.g. Anitha R"
|
||||
status={fieldErrs.name ? { type: 'error', message: fieldErrs.name } : undefined}
|
||||
/>
|
||||
<select
|
||||
<Selector
|
||||
label="Role"
|
||||
isRequired
|
||||
isDisabled={roles.length === 1}
|
||||
value={role || (roles.length === 1 ? roles[0].role : '')}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
disabled={roles.length === 1}
|
||||
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white cursor-pointer disabled:cursor-default"
|
||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
||||
>
|
||||
{roles.length !== 1 && <option value="">Choose a role…</option>}
|
||||
{roles.map((r) => (
|
||||
<option key={r.role_id} value={r.role}>{r.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
onChange={(v) => setRole(String(v ?? ''))}
|
||||
placeholder="Choose a role…"
|
||||
options={roles.map((r) => ({ value: r.role, label: r.label }))}
|
||||
status={fieldErrs.role ? { type: 'error', message: fieldErrs.role } : undefined}
|
||||
/>
|
||||
<TextInput
|
||||
label="PIN"
|
||||
isRequired
|
||||
// The 4-digit clamp stays on the setter, not on the component —
|
||||
// it is what stops a fifth keystroke ever reaching state.
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, '').slice(0, 4))}
|
||||
placeholder="4-digit PIN"
|
||||
inputMode="numeric"
|
||||
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white font-mono tracking-widest"
|
||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
||||
onChange={(v) => setPin(v.replace(/\D/g, '').slice(0, 4))}
|
||||
placeholder="4 digits"
|
||||
status={fieldErrs.pin ? { type: 'error', message: fieldErrs.pin } : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/[^\d+\s-]/g, '').slice(0, 16))}
|
||||
placeholder="Mobile number — used to sign in"
|
||||
inputMode="tel"
|
||||
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white font-mono"
|
||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
||||
onChange={(v) => setPhone(v.replace(/[^\d+\s-]/g, '').slice(0, 16))}
|
||||
placeholder="10 digits"
|
||||
status={fieldErrs.phone ? { type: 'error', message: fieldErrs.phone } : undefined}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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
|
||||
generated; the password is shown once, on the next screen.
|
||||
A username and password are also generated; the password is shown once, on the
|
||||
next screen.
|
||||
</p>
|
||||
|
||||
{formError && (
|
||||
<p className="text-[12px] mt-2 font-semibold" style={{ color: '#ef4444' }}>{formError}</p>
|
||||
)}
|
||||
{/* The mobile-number hint moved onto the field itself as a
|
||||
`description`, so this paragraph no longer repeats it. */}
|
||||
|
||||
<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}
|
||||
disabled={createMut.isPending}
|
||||
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50"
|
||||
style={{ background: BRAND }}
|
||||
>
|
||||
{createMut.isPending ? 'Creating…' : 'Create account'}
|
||||
</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>
|
||||
/>
|
||||
<Button
|
||||
label="Cancel"
|
||||
variant="secondary"
|
||||
onClick={() => { setShowForm(false); resetForm(); setFieldErrs({}); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -503,71 +537,53 @@ export default function PosStaffPanel({
|
||||
{editing && (
|
||||
<Modal onClose={() => setEditing(null)} title={`Edit ${editing.full_name}`}>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="Full name"
|
||||
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none"
|
||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
||||
/>
|
||||
<select
|
||||
<TextInput label="Full name" value={editName} onChange={(v) => setEditName(v)} />
|
||||
<Selector
|
||||
label="Role"
|
||||
isDisabled={cashierOnly}
|
||||
value={editRole}
|
||||
onChange={(e) => setEditRole(e.target.value)}
|
||||
disabled={cashierOnly}
|
||||
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 }}
|
||||
>
|
||||
{roles.map((r) => (
|
||||
<option key={r.role_id} value={r.role}>{r.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
onChange={(v) => setEditRole(String(v ?? ''))}
|
||||
options={roles.map((r) => ({ value: r.role, label: r.label }))}
|
||||
/>
|
||||
<TextInput
|
||||
label="PIN"
|
||||
value={editPin}
|
||||
onChange={(e) => setEditPin(e.target.value.replace(/\D/g, '').slice(0, 4))}
|
||||
placeholder="4-digit PIN"
|
||||
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 }}
|
||||
onChange={(v) => setEditPin(v.replace(/\D/g, '').slice(0, 4))}
|
||||
placeholder="4 digits"
|
||||
/>
|
||||
<input
|
||||
<TextInput
|
||||
label="Mobile number"
|
||||
description="What they type at the till to sign in."
|
||||
value={editPhone}
|
||||
onChange={(e) => setEditPhone(e.target.value.replace(/[^\d+\s-]/g, '').slice(0, 16))}
|
||||
placeholder="Mobile number — used to sign in"
|
||||
inputMode="tel"
|
||||
className="w-full rounded-lg px-3 py-2 text-[13px] outline-none font-mono"
|
||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
||||
onChange={(v) => setEditPhone(v.replace(/[^\d+\s-]/g, '').slice(0, 16))}
|
||||
/>
|
||||
<Selector
|
||||
label="Shift"
|
||||
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>
|
||||
{/* 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>}
|
||||
<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}
|
||||
disabled={updateMut.isPending}
|
||||
className="rounded-full px-4 py-1.5 text-[12.5px] font-bold text-white cursor-pointer disabled:opacity-50"
|
||||
style={{ background: 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>
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" onClick={() => setEditing(null)} />
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
@@ -781,26 +797,28 @@ function ShiftManager({
|
||||
{open && (
|
||||
<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">
|
||||
<input
|
||||
<TextInput
|
||||
label="Shift name"
|
||||
isRequired
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name — e.g. Morning"
|
||||
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
|
||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
||||
onChange={(v) => setName(v)}
|
||||
placeholder="e.g. Morning"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
|
||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
||||
{/* PHASE 7's TimeInput, landing here rather than as a separate
|
||||
step — these two fields are the reason it was on the list.
|
||||
It emits an ISO time string or undefined, so the `?? ''`
|
||||
keeps the existing string state contract unchanged. */}
|
||||
<TimeInput
|
||||
label="Starts"
|
||||
isRequired
|
||||
value={start as any}
|
||||
onChange={(v) => setStart((v ?? '') as string)}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={end}
|
||||
onChange={(e) => setEnd(e.target.value)}
|
||||
className="rounded-lg px-3 py-2 text-[13px] outline-none bg-white"
|
||||
style={{ border: `1px solid ${BORDER}`, color: TEXT }}
|
||||
<TimeInput
|
||||
label="Ends"
|
||||
isRequired
|
||||
value={end as any}
|
||||
onChange={(v) => setEnd((v ?? '') as string)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -947,24 +965,24 @@ function Modal({
|
||||
onClose: () => void;
|
||||
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 (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
|
||||
<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. */}
|
||||
<h3 className="text-[16px] font-extrabold mb-3 break-words" style={{ color: TEXT }}>
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog isOpen onOpenChange={(open) => { if (!open) onClose(); }} width={400} purpose="info">
|
||||
{/* Names come from user input and can be long and unbroken. */}
|
||||
<DialogHeader title={title} onOpenChange={(open) => { if (!open) onClose(); }} />
|
||||
{children}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
||||
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||
import { useNotify } from './Toasts';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
locationid?: number;
|
||||
@@ -24,6 +69,10 @@ interface CartItem {
|
||||
}
|
||||
|
||||
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 [search, setSearch] = useState('');
|
||||
@@ -236,7 +285,7 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
if (product) {
|
||||
handleProductScanned(product);
|
||||
} 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 ──────────────────────────────── */}
|
||||
{showManualSearch && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
|
||||
<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' }}>
|
||||
<PosModal onClose={() => setShowManualSearch(false)} width={500} maxHeight="85vh">
|
||||
<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">
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PosModal>
|
||||
)}
|
||||
|
||||
{/* ── MODAL: Customer CRM ───────────────────────────────────────── */}
|
||||
{showCustomerPrompt && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
|
||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<PosModal onClose={closeCustomerPrompt} width={400}>
|
||||
<div className="px-6 pt-6 pb-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">
|
||||
@@ -842,14 +888,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PosModal>
|
||||
)}
|
||||
|
||||
{/* ── MODAL: Parked Carts ───────────────────────────────────────── */}
|
||||
{showParkedPrompt && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
|
||||
<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' }}>
|
||||
<PosModal onClose={() => setShowParkedPrompt(false)} width={450} maxHeight="80vh">
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ── MODAL: Discount ───────────────────────────────────────────── */}
|
||||
{discountPromptItem && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
|
||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<PosModal onClose={() => setDiscountPromptItem(null)} width={400}>
|
||||
<div className="px-6 pt-6 pb-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">
|
||||
@@ -938,14 +980,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
Confirm Discount
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PosModal>
|
||||
)}
|
||||
|
||||
{/* ── MODAL: Payment ────────────────────────────────────────────── */}
|
||||
{showPayment && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
|
||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: '100%', maxWidth: '450px' }}>
|
||||
<PosModal onClose={() => setShowPayment(false)} width={450}>
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-slate-200 px-6 py-5 text-slate-900 flex items-start justify-between">
|
||||
<div>
|
||||
@@ -1104,14 +1144,12 @@ export default function PosView({ locationid, tenantId = FIESTA_TENANT_ID }: Pos
|
||||
Confirm {grandTotal < 0 ? 'Refund' : 'Payment'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PosModal>
|
||||
)}
|
||||
|
||||
{/* ── MODAL: Receipt ────────────────────────────────────────────── */}
|
||||
{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">
|
||||
<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' }}>
|
||||
<PosModal onClose={resetPos} width={400}>
|
||||
<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">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PosModal>
|
||||
)}
|
||||
|
||||
{/* ── MODAL: Daily Report ───────────────────────────────────────── */}
|
||||
{showReport && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 print:hidden">
|
||||
<div className="bg-white rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" style={{ width: '100%', maxWidth: '450px' }}>
|
||||
<PosModal onClose={() => setShowReport(false)} width={450}>
|
||||
<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">
|
||||
<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>
|
||||
</PosModal>
|
||||
)}
|
||||
</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 AwaitingApi from './AwaitingApi';
|
||||
import SalesRevenueReport from './SalesRevenueReport';
|
||||
import { useNotify } from './Toasts';
|
||||
|
||||
interface ReportsViewProps {
|
||||
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'];
|
||||
|
||||
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 [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all');
|
||||
const [activeStoreId, setActiveStoreId] = useState<number | 'all'>('all');
|
||||
@@ -433,7 +438,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
clearInterval(interval);
|
||||
setTimeout(() => {
|
||||
setExportingFormat(null);
|
||||
alert(`${format} ledger exported successfully.`);
|
||||
notifyOk(`${format} ledger exported.`);
|
||||
}, 450);
|
||||
return 100;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,10 @@ export default function RiderTelemetryPanel({ logs, riderName, isLoading }: Ride
|
||||
const lat = latest?.latitude || latest?.lat;
|
||||
const lon = latest?.longitude || latest?.lng;
|
||||
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 timestamp = latest?.logdate || latest?.timestamp;
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import AwaitingApi from './AwaitingApi';
|
||||
import AdminConsole from './AdminConsole';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import type { AuthUser } from '../services/auth';
|
||||
import { useNotify } from './Toasts';
|
||||
|
||||
type TabKey = 'profile' | 'outlets' | 'users';
|
||||
|
||||
@@ -128,6 +129,10 @@ export interface 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');
|
||||
|
||||
// Live tenant profile + outlets.
|
||||
@@ -166,9 +171,9 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
||||
});
|
||||
setShowEditStoreModal(false);
|
||||
setEditingStore(null);
|
||||
alert('Outlet updated successfully!');
|
||||
notifyOk('Outlet updated.');
|
||||
} 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 });
|
||||
setConfirmModalState({ isOpen: false, loc: null });
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to delete outlet.');
|
||||
notifyFail(err, 'Failed to delete outlet.');
|
||||
setConfirmModalState({ isOpen: false, loc: null });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -33,6 +33,12 @@ import { CardCarousel, ProductGallery } from './CatalogueGallery';
|
||||
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
||||
import CatalogueDetailPanel from './CatalogueDetailPanel';
|
||||
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';
|
||||
|
||||
@@ -79,6 +85,10 @@ function catBadgeClass(category: string): string {
|
||||
}
|
||||
|
||||
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 [view, setView] = useState<'catalogue' | 'inventory' | 'requests'>('catalogue');
|
||||
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 [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(() => {
|
||||
if (stockRequestsQ.data) {
|
||||
@@ -187,7 +201,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
if (picks[String(data.productid)]?.status === 'Received') return;
|
||||
if (updateRequestMutation.isPending) return;
|
||||
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;
|
||||
}
|
||||
updateRequestMutation.mutate(
|
||||
@@ -200,7 +214,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
},
|
||||
{
|
||||
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() } }));
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
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 safeQty = Math.max(1, Math.round(qty) || 1);
|
||||
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;
|
||||
}
|
||||
setPicks((prev) => {
|
||||
@@ -404,14 +418,16 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
<UploadCloud size={14} /> Upload Offline Sales
|
||||
</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' && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">Date:</span>
|
||||
<input
|
||||
type="date"
|
||||
value={requestDate}
|
||||
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 className="shrink-0 w-44">
|
||||
<DateInput
|
||||
label="Date"
|
||||
value={requestDate as any}
|
||||
onChange={(v) => setRequestDate((v ?? '') as string)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -436,20 +452,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{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 className="relative flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCategories.includes(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>
|
||||
<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 key={c} className="px-1 py-0.5">
|
||||
<CheckboxInput
|
||||
label={c}
|
||||
value={selectedCategories.includes(c)}
|
||||
onChange={() => toggleCategory(c)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -463,20 +472,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{['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 className="relative flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stockHealthFilter.includes(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>
|
||||
<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 key={h} className="px-1 py-0.5">
|
||||
<CheckboxInput
|
||||
label={h}
|
||||
value={stockHealthFilter.includes(h)}
|
||||
onChange={() => toggleStockHealth(h)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -521,20 +523,20 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
<Layers size={14} /> Filter
|
||||
</button>
|
||||
|
||||
<div className="relative hidden md:block md:w-64 h-[36px]">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={view === 'catalogue' ? 'Search catalogue...' : 'Search your stock...'}
|
||||
{/* `hasClear` replaces the absolutely-positioned X button, and
|
||||
`startIcon` replaces the absolutely-positioned magnifier — so
|
||||
the wrapper no longer needs `relative` or a fixed height to
|
||||
anchor them. */}
|
||||
<div className="hidden md:block md:w-64">
|
||||
<TextInput
|
||||
label="Search"
|
||||
isLabelHidden
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
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"
|
||||
onChange={(v) => setSearch(v)}
|
||||
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>
|
||||
@@ -881,24 +883,15 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 block mb-2">Custom Amount</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
|
||||
<Boxes size={14} className="text-slate-400 group-focus-within:text-[#662582] transition-colors" />
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={tempQty}
|
||||
onChange={(e) => setTempQty(Math.max(1, parseInt(e.target.value) || 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"
|
||||
/>
|
||||
<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>
|
||||
{/* NumberInput enforces the minimum itself, so the Math.max guard
|
||||
stays only as the floor for a pasted or cleared value. */}
|
||||
<NumberInput
|
||||
label="Custom quantity"
|
||||
min={1}
|
||||
value={tempQty}
|
||||
onChange={(v) => setTempQty(Math.max(1, Number(v) || 1))}
|
||||
startIcon={<Boxes size={14} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Total & Submit */}
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
* 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 { type LucideIcon } from 'lucide-react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import NavRail, { type NavRailItem } from './NavRail';
|
||||
|
||||
/** One entry in the super admin sidebar. */
|
||||
export interface SuperAdminNavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
export type SuperAdminNavItem = NavRailItem;
|
||||
|
||||
interface SuperAdminSidebarProps {
|
||||
items: SuperAdminNavItem[];
|
||||
@@ -21,53 +24,6 @@ interface SuperAdminSidebarProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Super Admin page's left rail. Visually identical to the admin `Sidebar`.
|
||||
*/
|
||||
export default function SuperAdminSidebar({ items, isOpen, onClose }: SuperAdminSidebarProps) {
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{/* 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>
|
||||
</>
|
||||
);
|
||||
return <NavRail items={items} basePath="/superadmin" isOpen={isOpen} onClose={onClose} itemRadius="none" />;
|
||||
}
|
||||
|
||||
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 { useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||
import { str as fstr } from '../services/fiestaApi';
|
||||
import { useNotify } from './Toasts';
|
||||
|
||||
export interface TrialProduct {
|
||||
id: string | number;
|
||||
@@ -21,6 +22,10 @@ interface 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 [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
@@ -49,7 +54,7 @@ export default function TrialBatchDrawer({ isOpen, onClose, product, tenantId, o
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (selectedLocationIds.length === 0) {
|
||||
alert("Please select at least one store delivery hub.");
|
||||
notifyFail('Select at least one store delivery hub.');
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
|
||||
@@ -41,6 +41,7 @@ import PosConsoleView from './PosConsoleView';
|
||||
import PosErrorBoundary from './PosErrorBoundary';
|
||||
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
||||
import ComparisonModal from './ComparisonModal';
|
||||
import { useNotify } from './Toasts';
|
||||
interface UserStorePageProps {
|
||||
/** Returns to the login screen. */
|
||||
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).
|
||||
*/
|
||||
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 [showQrModal, setShowQrModal] = useState(false);
|
||||
const location = useLocation();
|
||||
@@ -151,7 +156,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
.toUpperCase() || 'NA';
|
||||
|
||||
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
|
||||
// mapping so the console renders identically to the admin's store view.
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
* 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 { type LucideIcon } from 'lucide-react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import NavRail, { type NavRailItem } from './NavRail';
|
||||
|
||||
/** One entry in the user store sidebar. Add to `UserStorePage`'s `NAV_ITEMS`. */
|
||||
export interface UserNavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
export type UserNavItem = NavRailItem;
|
||||
|
||||
interface UserStoreSidebarProps {
|
||||
items: UserNavItem[];
|
||||
@@ -21,60 +24,6 @@ interface UserStoreSidebarProps {
|
||||
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) {
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{/* 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>
|
||||
</>
|
||||
);
|
||||
return <NavRail items={items} basePath="/store" isOpen={isOpen} onClose={onClose} itemRadius="lg" />;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useFiestaUsers, useFiestaCreateUser, useFiestaUpdateUser, useFiestaRide
|
||||
import { useAppRoles } from '../services/queries';
|
||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
|
||||
import { useNotify } from './Toasts';
|
||||
|
||||
interface UsersPanelProps {
|
||||
tenantId?: number;
|
||||
@@ -83,6 +84,10 @@ const FALLBACK_ROLE_CHOICES = [
|
||||
];
|
||||
|
||||
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 createUserMut = useFiestaCreateUser();
|
||||
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 });
|
||||
}
|
||||
} 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) => {
|
||||
e.preventDefault();
|
||||
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;
|
||||
}
|
||||
if (!/^\d{10}$/.test(newUser.contactno.trim())) {
|
||||
alert('Contact number must be exactly 10 digits.');
|
||||
notifyFail('Contact number must be exactly 10 digits.');
|
||||
return;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newUser.email.trim())) {
|
||||
alert('Please enter a valid email address.');
|
||||
notifyFail('Please enter a valid email address.');
|
||||
return;
|
||||
}
|
||||
if (!Number(newUser.roleid)) {
|
||||
alert('Please select a role.');
|
||||
notifyFail('Please select a role.');
|
||||
return;
|
||||
}
|
||||
if (!newUser.locationid) {
|
||||
alert('Please select the store this user belongs to.');
|
||||
notifyFail('Please select the store this user belongs to.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -288,9 +293,9 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
});
|
||||
setShowAddUserModal(false);
|
||||
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) {
|
||||
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);
|
||||
} 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">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -18,6 +18,41 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
export const BRAND = '#662582';
|
||||
@@ -114,13 +149,13 @@ function BrandMark() {
|
||||
|
||||
/** Live / loading / error status line used under the header title. */
|
||||
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 (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold" style={{ color: TEXT_2 }}>
|
||||
<span
|
||||
className={`rounded-full ${state === 'loading' ? 'animate-pulse' : ''}`}
|
||||
style={{ width: 8, height: 8, background: color, boxShadow: state === 'live' ? `0 0 0 4px ${color}2e` : undefined }}
|
||||
/>
|
||||
<StatusDot variant={variant} label={label} isPulsing={state === 'loading'} />
|
||||
{label}
|
||||
</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"
|
||||
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 className="mt-2 w-full">
|
||||
@@ -276,21 +313,20 @@ export function SearchPill({ value, onChange, placeholder, color = BRAND, inputR
|
||||
// ── Card shells & table head cell ────────────────────────────────────────────────
|
||||
export function Card({ children, className = '', flush }: { children: React.ReactNode; className?: string; flush?: 'top' }) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-white border ${flush === 'top' ? 'rounded-b-2xl' : 'rounded-2xl'} ${className}`}
|
||||
style={{ borderColor: BORDER }}
|
||||
>
|
||||
// padding={0} because every caller already supplies its own padding
|
||||
// classes; letting Astryx add its default would shift ten screens at once.
|
||||
<AxCard padding={0} className={`${flush === 'top' ? 'rounded-b-2xl rounded-t-none' : ''} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
</AxCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** Filter/tab bar paper that visually joins the table below it (flat bottom). */
|
||||
export function FilterBar({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||
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}
|
||||
</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 }) {
|
||||
// `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 (
|
||||
<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">
|
||||
<table className="w-full text-sm text-left whitespace-nowrap" style={{ minWidth }}>
|
||||
<thead className="sticky top-0 z-10" style={TH_STYLE}>
|
||||
<tr>
|
||||
<AxTable style={{ minWidth }}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{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>
|
||||
</thead>
|
||||
<tbody className="divide-y" style={{ borderColor: DIVIDER }}>
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{children}</TableBody>
|
||||
</AxTable>
|
||||
</div>
|
||||
{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.
|
||||
*/
|
||||
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 (
|
||||
<div
|
||||
className={`animate-pulse bg-slate-200/60 rounded-md ${className}`}
|
||||
style={style}
|
||||
/>
|
||||
<div className={className} style={style}>
|
||||
<AxSkeleton width="100%" height="100%" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -347,16 +392,14 @@ export function Skeleton({ className = '', style }: { className?: string; style?
|
||||
* 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' }) {
|
||||
// Straight swap. Astryx's version also opens on keyboard focus, which the
|
||||
// hover-only original never did — a real accessibility gain for free.
|
||||
return (
|
||||
<div className="relative flex items-center group cursor-help w-max">
|
||||
{children}
|
||||
<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`}>
|
||||
<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">
|
||||
{content}
|
||||
</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>
|
||||
// Astryx names placements by flow direction, not by physical edge — so
|
||||
// top/bottom become above/below. The public prop keeps its old names.
|
||||
<AxTooltip content={content} placement={position === 'top' ? 'above' : 'below'}>
|
||||
<span className="inline-flex items-center cursor-help w-max">{children}</span>
|
||||
</AxTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -376,20 +419,37 @@ export function SlideDrawer({
|
||||
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(() => {
|
||||
if (isOpen) document.body.style.overflow = 'hidden';
|
||||
else document.body.style.overflow = '';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, [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;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200]">
|
||||
{/* Muted Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-900/40 transition-opacity"
|
||||
<div
|
||||
className="absolute inset-0 bg-slate-900/40 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
|
||||
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 "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 {
|
||||
--font-sans: "Public Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
46
src/main.tsx
46
src/main.tsx
@@ -34,15 +34,47 @@ const queryClient = new QueryClient({
|
||||
});
|
||||
|
||||
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(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompareProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</CompareProvider>
|
||||
</QueryClientProvider>
|
||||
{/*
|
||||
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}>
|
||||
<CompareProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</CompareProvider>
|
||||
</QueryClientProvider>
|
||||
</ToastViewport>
|
||||
</Theme>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -42,7 +42,10 @@ export function buildStoreQrPayload(opts: { tenantid: number; locationid: number
|
||||
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();
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
// 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