feat: relocate orders and deliveries to store console & polish store cover images

This commit is contained in:
Suriya
2026-06-03 18:20:43 +05:30
commit 6eaeb5c4a7
32 changed files with 13430 additions and 0 deletions

View File

@@ -0,0 +1,511 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Building2,
Store,
Truck,
CreditCard,
SlidersHorizontal,
MapPin,
Phone,
Mail,
Check,
RotateCcw,
CheckCircle2,
} from 'lucide-react';
import { useFiestaAllTenants, useFiestaTenantLocations } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
interface SettingsViewProps {
tenantId?: number;
}
type TabKey = 'profile' | 'outlets' | 'delivery' | 'payment' | 'preferences';
/** Locally-persisted merchant preferences (survive reload via localStorage). */
interface MerchantSettings {
// Business profile (seeded from live tenant data, then locally editable)
contactEmail: string;
contactPhone: string;
minOrderValue: number;
// Delivery
deliveryCharge: number;
prepMins: number;
deliveryWindowMins: number;
cancelWindowSecs: number;
autoAssignRider: boolean;
// Payment & tax
defaultTaxPercent: number;
codEnabled: boolean;
onlinePaymentEnabled: boolean;
// Preferences
defaultRegion: string;
defaultNewUserRole: number;
orderNotifications: boolean;
lowStockAlerts: boolean;
dailySummaryEmail: boolean;
syncInterval: number;
sandboxMode: boolean;
}
const STORAGE_KEY = 'merchant-settings-v1';
const DEFAULTS: MerchantSettings = {
contactEmail: '',
contactPhone: '',
minOrderValue: 0,
deliveryCharge: 30,
prepMins: 15,
deliveryWindowMins: 45,
cancelWindowSecs: 60,
autoAssignRider: true,
defaultTaxPercent: 5,
codEnabled: true,
onlinePaymentEnabled: true,
defaultRegion: 'Coimbatore',
defaultNewUserRole: 4,
orderNotifications: true,
lowStockAlerts: true,
dailySummaryEmail: false,
syncInterval: 5,
sandboxMode: false,
};
function loadSettings(): { settings: MerchantSettings; hadSaved: boolean } {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return { settings: { ...DEFAULTS, ...JSON.parse(raw) }, hadSaved: true };
} catch {
/* ignore corrupt storage */
}
return { settings: { ...DEFAULTS }, hadSaved: false };
}
// ── Small presentational helpers ────────────────────────────────────────────
function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) {
return (
<label className="relative inline-flex items-center cursor-pointer shrink-0">
<input type="checkbox" checked={checked} onChange={onChange} className="sr-only peer" />
<div className="w-9 h-5 bg-zinc-200 rounded-full peer peer-focus:ring-0 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-zinc-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-[#581c87]" />
</label>
);
}
function Row({
title,
desc,
children,
}: {
title: string;
desc?: string;
children: React.ReactNode;
}) {
return (
<div className="flex justify-between items-center gap-md p-sm bg-zinc-50 rounded-lg border border-[#e2e8f0]/45">
<div className="min-w-0">
<h4 className="font-sans font-bold text-xs text-[#0f172a]">{title}</h4>
{desc && <p className="text-zinc-400 text-[10px] mt-xs">{desc}</p>}
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
const numberInputCls =
'w-24 border border-[#e2e8f0] rounded-lg p-1.5 text-right font-semibold text-zinc-700 bg-white outline-none focus:ring-1 focus:ring-[#581c87]';
const textInputCls =
'w-full border border-[#e2e8f0] rounded-lg p-sm bg-white outline-none focus:ring-1 focus:ring-[#581c87] text-zinc-700 font-medium';
const selectCls =
'border border-[#e2e8f0] bg-white rounded-lg p-1.5 font-semibold text-zinc-700 outline-none cursor-pointer';
export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsViewProps) {
const [activeTab, setActiveTab] = useState<TabKey>('profile');
// Live tenant profile + outlets.
const tenantsQ = useFiestaAllTenants({ pagesize: 50 });
const tenant = (tenantsQ.data ?? []).find((t) => Number(t.tenantid) === tenantId) || null;
const locationsQ = useFiestaTenantLocations(tenantId);
const outlets = locationsQ.data ?? [];
// Persisted preferences.
const initial = useRef(loadSettings());
const [form, setForm] = useState<MerchantSettings>(initial.current.settings);
const [saved, setSaved] = useState<MerchantSettings>(initial.current.settings);
const [toast, setToast] = useState<string | null>(null);
// First-run seeding: if nothing was saved yet, fill contact/min-order/region
// from the live tenant once it arrives.
const seededRef = useRef(initial.current.hadSaved);
useEffect(() => {
if (seededRef.current || !tenant) return;
seededRef.current = true;
const seed = (prev: MerchantSettings): MerchantSettings => ({
...prev,
contactEmail: prev.contactEmail || fstr(tenant.primaryemail),
contactPhone: prev.contactPhone || fstr(tenant.primarycontact),
minOrderValue: prev.minOrderValue || fnum(tenant.minorder),
defaultRegion: prev.defaultRegion || fstr(tenant.city) || 'Coimbatore',
});
setForm(seed);
setSaved(seed);
}, [tenant]);
const dirty = useMemo(() => JSON.stringify(form) !== JSON.stringify(saved), [form, saved]);
const set = <K extends keyof MerchantSettings>(key: K, value: MerchantSettings[K]) =>
setForm((f) => ({ ...f, [key]: value }));
const handleSave = () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch {
/* ignore quota errors */
}
setSaved(form);
setToast('Settings saved');
window.setTimeout(() => setToast(null), 2200);
};
const handleReset = () => setForm(saved);
const tabs: Array<{ key: TabKey; label: string; icon: typeof Building2 }> = [
{ key: 'profile', label: 'Business Profile', icon: Building2 },
{ key: 'outlets', label: 'Outlets', icon: Store },
{ key: 'delivery', label: 'Delivery', icon: Truck },
{ key: 'payment', label: 'Payment & Tax', icon: CreditCard },
{ key: 'preferences', label: 'Preferences', icon: SlidersHorizontal },
];
const roleOptions = [1, 2, 3, 4, 6];
return (
<div className="space-y-lg animate-in fade-in duration-300 relative">
{/* Header */}
<div>
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">Settings</h1>
<p className="text-zinc-500 font-sans text-xs mt-1">
Manage your store profile, outlets, delivery, payments, and workspace preferences.
</p>
<div className="mt-1.5">
{tenantsQ.isLoading ? (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading live profile
</span>
) : tenant ? (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {fstr(tenant.tenantname)} · Tenant {tenantId}
</span>
) : (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-rose-600 uppercase tracking-wide">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Tenant profile unavailable
</span>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-gutter items-start">
{/* Tab rail */}
<nav className="lg:col-span-1 bg-white border border-[#e2e8f0] rounded-xl p-2 shadow-sm flex lg:flex-col gap-1 overflow-x-auto">
{tabs.map((t) => {
const Icon = t.icon;
const active = activeTab === t.key;
return (
<button
key={t.key}
onClick={() => setActiveTab(t.key)}
className={`flex items-center gap-sm px-sm py-2 rounded-lg text-xs font-semibold transition-colors whitespace-nowrap cursor-pointer ${
active ? 'bg-[#faf5ff] text-[#581c87]' : 'text-zinc-600 hover:bg-zinc-50'
}`}
>
<Icon size={15} className={active ? 'text-[#581c87]' : 'text-zinc-400'} />
{t.label}
</button>
);
})}
</nav>
{/* Panel */}
<div className="lg:col-span-3 space-y-gutter text-xs font-sans">
{activeTab === 'profile' && (
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm space-y-md">
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">
Business Profile
</span>
{/* Live identity (read-only) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-sm">
<div className="p-sm bg-zinc-50 rounded-lg border border-[#e2e8f0]/45">
<span className="text-[9px] text-zinc-400 uppercase font-bold tracking-widest">Store Name</span>
<p className="font-bold text-[#0f172a] mt-0.5">{fstr(tenant?.tenantname) || '—'}</p>
</div>
<div className="p-sm bg-zinc-50 rounded-lg border border-[#e2e8f0]/45">
<span className="text-[9px] text-zinc-400 uppercase font-bold tracking-widest">Legal / Company</span>
<p className="font-bold text-[#0f172a] mt-0.5">{fstr(tenant?.companyname) || '—'}</p>
</div>
<div className="p-sm bg-zinc-50 rounded-lg border border-[#e2e8f0]/45">
<span className="text-[9px] text-zinc-400 uppercase font-bold tracking-widest">Category</span>
<p className="font-bold text-[#0f172a] mt-0.5">{fstr(tenant?.subcategoryname) || `Category ${fnum(tenant?.categoryid)}`}</p>
</div>
<div className="p-sm bg-zinc-50 rounded-lg border border-[#e2e8f0]/45 flex items-center justify-between">
<div>
<span className="text-[9px] text-zinc-400 uppercase font-bold tracking-widest">Account Status</span>
<p className="font-bold text-[#0f172a] mt-0.5">{fstr(tenant?.status) || '—'}</p>
</div>
<span className={`px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
fstr(tenant?.status).toLowerCase() === 'active'
? 'text-emerald-700 bg-emerald-100'
: 'text-zinc-500 bg-zinc-200'
}`}>
{fnum(tenant?.approved) === 1 ? 'Approved' : 'Pending'}
</span>
</div>
</div>
<div className="p-sm bg-zinc-50 rounded-lg border border-[#e2e8f0]/45 flex items-start gap-sm">
<MapPin size={13} className="text-zinc-400 shrink-0 mt-0.5" />
<div>
<span className="text-[9px] text-zinc-400 uppercase font-bold tracking-widest">Registered Address</span>
<p className="text-zinc-700 font-medium mt-0.5 leading-relaxed">
{fstr(tenant?.address) || '—'}
{tenant?.city ? ` · ${fstr(tenant.city)}, ${fstr(tenant.state)} ${fstr(tenant.postcode)}` : ''}
</p>
</div>
</div>
{/* Editable contact (persisted locally) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-sm pt-xs">
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px] flex items-center gap-1">
<Mail size={11} /> Contact Email
</label>
<input
type="email"
value={form.contactEmail}
onChange={(e) => set('contactEmail', e.target.value)}
className={textInputCls}
placeholder="store@example.com"
/>
</div>
<div className="space-y-1">
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px] flex items-center gap-1">
<Phone size={11} /> Contact Phone
</label>
<input
type="text"
value={form.contactPhone}
onChange={(e) => set('contactPhone', e.target.value)}
className={textInputCls}
placeholder="9876543210"
/>
</div>
</div>
<p className="text-[10px] text-zinc-400">
Identity fields above are read live from your tenant record. Contact details are saved to this workspace.
</p>
</div>
)}
{activeTab === 'outlets' && (
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm space-y-md">
<div className="flex justify-between items-center pb-xs border-b border-[#f1f5f9]">
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">Outlet Locations</span>
<span className="text-[10px] text-[#581c87] font-bold bg-purple-50 px-2 py-0.5 rounded border border-purple-100">
{locationsQ.isLoading ? 'Loading…' : `${outlets.length} outlet${outlets.length === 1 ? '' : 's'}`}
</span>
</div>
{locationsQ.isLoading ? (
<div className="text-center py-lg text-zinc-400">Loading live outlets</div>
) : outlets.length === 0 ? (
<div className="text-center py-lg text-zinc-400">No outlets found for this tenant.</div>
) : (
<div className="space-y-sm max-h-[28rem] overflow-y-auto">
{outlets.map((loc, i) => (
<div key={fstr(loc.locationid) || i} className="p-sm border border-[#e2e8f0] rounded-lg bg-[#f8fafc]/40">
<div className="flex justify-between items-start gap-md">
<div className="min-w-0">
<p className="font-bold text-[#0f172a] truncate">{fstr(loc.locationname)}</p>
<p className="text-[10px] text-zinc-500 mt-0.5 flex items-center gap-1">
<MapPin size={10} className="shrink-0 text-zinc-400" />
<span className="truncate">{fstr(loc.suburb)}, {fstr(loc.city)} {fstr(loc.postcode)}</span>
</p>
</div>
<span className={`shrink-0 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
fstr(loc.status).toLowerCase() === 'active'
? 'text-emerald-600 bg-emerald-50 border border-emerald-100'
: 'text-zinc-500 bg-zinc-100'
}`}>
{fstr(loc.status) || '—'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 mt-sm text-center">
<div className="bg-white border border-[#e2e8f0]/60 rounded p-1">
<p className="text-[9px] text-zinc-400 uppercase font-bold">Hours</p>
<p className="font-mono font-semibold text-zinc-700 text-[10px] mt-0.5">
{fstr(loc.opentime).slice(11, 16) || '—'}{fstr(loc.closetime).slice(11, 16) || '—'}
</p>
</div>
<div className="bg-white border border-[#e2e8f0]/60 rounded p-1">
<p className="text-[9px] text-zinc-400 uppercase font-bold">Radius</p>
<p className="font-mono font-semibold text-zinc-700 text-[10px] mt-0.5">{fnum(loc.deliveryradius)} m</p>
</div>
<div className="bg-white border border-[#e2e8f0]/60 rounded p-1">
<p className="text-[9px] text-zinc-400 uppercase font-bold">ETA</p>
<p className="font-mono font-semibold text-zinc-700 text-[10px] mt-0.5">{fnum(loc.deliverymins)} min</p>
</div>
</div>
</div>
))}
</div>
)}
<p className="text-[10px] text-zinc-400">Outlets are read live from your tenant. Add or edit them in the Stores section.</p>
</div>
)}
{activeTab === 'delivery' && (
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm space-y-md">
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">
Delivery Settings
</span>
<div className="space-y-sm">
<Row title="Default Delivery Charge" desc="Flat fee added to each delivery order.">
<div className="flex items-center gap-1">
<span className="text-zinc-400 font-bold"></span>
<input type="number" min={0} value={form.deliveryCharge}
onChange={(e) => set('deliveryCharge', Number(e.target.value))} className={numberInputCls} />
</div>
</Row>
<Row title="Preparation Time" desc="Minutes a store needs before pickup.">
<input type="number" min={0} value={form.prepMins}
onChange={(e) => set('prepMins', Number(e.target.value))} className={numberInputCls} />
</Row>
<Row title="Delivery Window" desc="Target minutes from dispatch to doorstep.">
<input type="number" min={0} value={form.deliveryWindowMins}
onChange={(e) => set('deliveryWindowMins', Number(e.target.value))} className={numberInputCls} />
</Row>
<Row title="Cancellation Window" desc="Seconds a customer can cancel for free.">
<input type="number" min={0} value={form.cancelWindowSecs}
onChange={(e) => set('cancelWindowSecs', Number(e.target.value))} className={numberInputCls} />
</Row>
<Row title="Auto-assign Rider" desc="Automatically dispatch the nearest available rider.">
<Toggle checked={form.autoAssignRider} onChange={() => set('autoAssignRider', !form.autoAssignRider)} />
</Row>
</div>
</div>
)}
{activeTab === 'payment' && (
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm space-y-md">
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">
Payment & Tax
</span>
<div className="space-y-sm">
<Row title="Default Tax Rate" desc="Applied to taxable catalogue items.">
<div className="flex items-center gap-1">
<input type="number" min={0} max={100} value={form.defaultTaxPercent}
onChange={(e) => set('defaultTaxPercent', Number(e.target.value))} className={numberInputCls} />
<span className="text-zinc-400 font-bold">%</span>
</div>
</Row>
<Row title="Minimum Order Value" desc="Smallest order a customer can place.">
<div className="flex items-center gap-1">
<span className="text-zinc-400 font-bold"></span>
<input type="number" min={0} value={form.minOrderValue}
onChange={(e) => set('minOrderValue', Number(e.target.value))} className={numberInputCls} />
</div>
</Row>
<Row title="Cash on Delivery" desc="Allow customers to pay on delivery.">
<Toggle checked={form.codEnabled} onChange={() => set('codEnabled', !form.codEnabled)} />
</Row>
<Row title="Online Payments" desc="Accept UPI / card / wallet at checkout.">
<Toggle checked={form.onlinePaymentEnabled} onChange={() => set('onlinePaymentEnabled', !form.onlinePaymentEnabled)} />
</Row>
<div className="p-sm bg-purple-50 border border-purple-100 rounded-lg text-[#581c87] text-[11px] font-medium">
Live tenant payment configuration code: <strong>{fnum(tenant?.paymenttype) || '—'}</strong>
</div>
</div>
</div>
)}
{activeTab === 'preferences' && (
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm space-y-md">
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">
Workspace Preferences
</span>
<div className="space-y-sm">
<Row title="Default Region" desc="Region applied to new outlets and reports.">
<input type="text" value={form.defaultRegion}
onChange={(e) => set('defaultRegion', e.target.value)} className={`${numberInputCls} w-40 text-left`} />
</Row>
<Row title="Default Role for New Users" desc="Pre-selected role in the Add User dialog.">
<select value={form.defaultNewUserRole}
onChange={(e) => set('defaultNewUserRole', Number(e.target.value))} className={selectCls}>
{roleOptions.map((r) => (
<option key={r} value={r}>{roleName(r)}</option>
))}
</select>
</Row>
<Row title="Data Sync Interval" desc="How often live data refreshes from the API.">
<select value={form.syncInterval}
onChange={(e) => set('syncInterval', Number(e.target.value))} className={selectCls}>
<option value={1}>Every 1 min</option>
<option value={5}>Every 5 mins</option>
<option value={15}>Every 15 mins</option>
<option value={30}>Every 30 mins</option>
</select>
</Row>
<Row title="Order Notifications" desc="Alert on every new incoming order.">
<Toggle checked={form.orderNotifications} onChange={() => set('orderNotifications', !form.orderNotifications)} />
</Row>
<Row title="Low-stock Alerts" desc="Notify when an SKU drops below threshold.">
<Toggle checked={form.lowStockAlerts} onChange={() => set('lowStockAlerts', !form.lowStockAlerts)} />
</Row>
<Row title="Daily Summary Email" desc="Email a closing-hours performance digest.">
<Toggle checked={form.dailySummaryEmail} onChange={() => set('dailySummaryEmail', !form.dailySummaryEmail)} />
</Row>
<Row title="Sandbox Mode" desc="Simulate warning states without affecting live ops.">
<Toggle checked={form.sandboxMode} onChange={() => set('sandboxMode', !form.sandboxMode)} />
</Row>
</div>
</div>
)}
{/* Save / Reset — lives with the settings card, not pinned to the screen */}
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm flex flex-col sm:flex-row sm:items-center justify-between gap-sm">
<span className={`text-xs font-medium ${dirty ? 'text-amber-600' : 'text-zinc-400'}`}>
{dirty ? '● You have unsaved changes' : 'All changes saved'}
</span>
<div className="flex gap-sm">
<button
onClick={handleReset}
disabled={!dirty}
className="px-4 py-2 border border-[#e2e8f0] rounded-lg text-xs font-semibold text-zinc-600 hover:bg-zinc-50 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
>
<RotateCcw size={13} /> Reset
</button>
<button
onClick={handleSave}
disabled={!dirty}
className="px-4 py-2 bg-[#581c87] text-white rounded-lg text-xs font-bold hover:bg-purple-800 cursor-pointer shadow-sm disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
>
<Check size={13} /> Save Changes
</button>
</div>
</div>
</div>
</div>
{/* Toast */}
{toast && (
<div className="fixed bottom-md right-md z-[130] bg-[#0f172a] text-white px-4 py-2.5 rounded-lg shadow-2xl flex items-center gap-2 text-xs font-semibold animate-in slide-in-from-bottom-2 fade-in duration-200">
<CheckCircle2 size={15} className="text-emerald-400" />
{toast}
</div>
)}
</div>
);
}