Files
daily_merchant_web/src/components/SettingsView.tsx
2026-06-04 11:40:06 +05:30

520 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Building2,
Store,
Truck,
CreditCard,
SlidersHorizontal,
Users,
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';
import UsersPanel from './UsersPanel';
interface SettingsViewProps {
tenantId?: number;
}
type TabKey = 'profile' | 'outlets' | 'users' | '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: 'users', label: 'Users & Access', icon: Users },
{ 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 === 'users' && (
<UsersPanel tenantId={tenantId} defaultNewUserRole={form.defaultNewUserRole} />
)}
{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.
Hidden on the Users tab, which manages accounts via the live API. */}
<div className={`bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm flex-col sm:flex-row sm:items-center justify-between gap-sm ${activeTab === 'users' ? 'hidden' : 'flex'}`}>
<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>
);
}