dispatch page

This commit is contained in:
Gokul
2026-06-12 14:45:06 +05:30
parent d8c1517239
commit 5378f2df1f
34 changed files with 4451 additions and 1744 deletions

View File

@@ -9,27 +9,29 @@
* Self-contained: search box, role filter, live query, and Add User modal.
*/
import React, { useState } from 'react';
import {
Users,
Search,
X,
Plus,
ShieldAlert,
Shield,
User,
Mail,
Phone,
MapPin,
Lock,
import React, { useState, useEffect } from 'react';
import {
Users,
Search,
X,
Plus,
ShieldAlert,
Shield,
User,
Mail,
Phone,
MapPin,
UserCheck,
Check,
SlidersHorizontal,
Coins
Coins,
Store,
Bike
} from 'lucide-react';
import { useFiestaUsers, useFiestaCreateUser } from '../services/fiestaQueries';
import { useFiestaUsers, useFiestaCreateUser, useFiestaRiderShifts, useFiestaTenantLocations } from '../services/fiestaQueries';
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';
interface UsersPanelProps {
tenantId?: number;
@@ -45,7 +47,7 @@ const USER_AVATARS = [
const ROLE_THEMES: Record<number, { bg: string; text: string; border: string; label: string }> = {
1: { bg: 'bg-rose-50/75', text: 'text-rose-700', border: 'border-rose-100', label: 'Owner' },
2: { bg: 'bg-amber-50/75', text: 'text-amber-700', border: 'border-amber-100', label: 'Manager' },
2: { bg: 'bg-amber-50/75', text: 'text-amber-700', border: 'border-amber-100', label: 'Admin' },
3: { bg: 'bg-blue-50/75', text: 'text-blue-700', border: 'border-blue-100', label: 'Admin' },
4: { bg: 'bg-emerald-50/75', text: 'text-emerald-700', border: 'border-emerald-100', label: 'Staff' },
6: { bg: 'bg-indigo-50/75', text: 'text-indigo-700', border: 'border-indigo-100', label: 'Cashier' },
@@ -63,7 +65,7 @@ const ROLE_META: Record<number, { icon: typeof ShieldAlert; desc: string }> = {
/** Fallback role choices when the app-roles API returns nothing. */
const FALLBACK_ROLE_CHOICES = [
{ id: 1, label: 'Owner', desc: 'Full business access', icon: ShieldAlert },
{ id: 2, label: 'Manager', desc: 'Operations control', icon: Shield },
{ id: 2, label: 'Admin', desc: 'Operations control', icon: Shield },
{ id: 3, label: 'Admin', desc: 'Manage store settings', icon: SlidersHorizontal },
{ id: 4, label: 'Staff', desc: 'Standard staff duties', icon: User },
{ id: 6, label: 'Cashier', desc: 'Checkout & registers', icon: Coins },
@@ -76,34 +78,81 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
// Selectable roles for the Add User modal — driven by the live app-roles API,
// matched to local icon/desc styling by roleid; falls back to the static list.
// Selectable roles for the Add User modal - limited to Staff and Rider.
const roleChoices = React.useMemo(() => {
const rows = rolesQ.data ?? [];
const mapped = rows
.map((r) => {
const id = fnum((r as Record<string, unknown>).roleid);
const label =
fstr((r as Record<string, unknown>).rolename) ||
fstr((r as Record<string, unknown>).name) ||
roleName(id);
const meta = ROLE_META[id];
return { id, label, desc: meta?.desc ?? '', icon: meta?.icon ?? User };
})
.filter((r) => r.id > 0);
return mapped.length ? mapped : FALLBACK_ROLE_CHOICES;
}, [rolesQ.data]);
return [
{ id: 4, label: 'Staff', desc: 'Standard store staff duties', icon: User, configid: 15 },
{ id: 5, label: 'Rider', desc: 'Delivery fleet rider', icon: Bike, configid: 6 },
];
}, []);
const [search, setSearch] = useState('');
const [userRoleFilter, setUserRoleFilter] = useState<number | 'ALL'>('ALL');
const [userRoleFilter, setUserRoleFilter] = useState<string | 'ALL'>('ALL');
const [showAddUserModal, setShowAddUserModal] = useState(false);
const [newUser, setNewUser] = useState({
firstname: '',
lastname: '',
email: '',
contactno: '',
password: '',
roleid: defaultNewUserRole,
locationid: 0,
applocationid: 0,
address: '',
suburb: '',
city: '',
state: '',
postcode: '',
latitude: '',
longitude: '',
shiftid: 0,
});
// Stores/branches for this merchant — the new user is bound to the store the
// admin picks (its locationid + applocationid go into the create payload).
const locationsQ = useFiestaTenantLocations(tenantId);
const allStores = (locationsQ.data ?? [])
.map((l) => ({
locationid: fnum((l as Record<string, unknown>).locationid),
applocationid: fnum((l as Record<string, unknown>).applocationid) || 1,
name: fstr((l as Record<string, unknown>).locationname) || `Store ${fnum((l as Record<string, unknown>).locationid)}`,
address: fstr((l as Record<string, unknown>).address),
status: fstr((l as Record<string, unknown>).status),
}))
.filter((s) => s.locationid > 0);
// Prefer Active stores (per the create-staff flow); fall back to all if a tenant
// doesn't flag status, so the picker is never empty.
const activeStores = allStores.filter((s) => s.status.toLowerCase() === 'active');
const storeOptions = activeStores.length ? activeStores : allStores;
// Auto-bind when the merchant has exactly one store (nothing to choose).
useEffect(() => {
if (!newUser.locationid && storeOptions.length === 1) {
setNewUser((u) => ({ ...u, locationid: storeOptions[0].locationid, applocationid: storeOptions[0].applocationid }));
}
}, [storeOptions.length, newUser.locationid]);
// Rider-shift picker — shown only when a rider role is selected (parity with the
// merchant_web create form). Shifts come from the live partners/getridershifts.
const selectedRole = roleChoices.find((r) => r.id === newUser.roleid);
const isRiderRole = (selectedRole?.label || '').toLowerCase().includes('rider') || newUser.roleid === 5;
const shiftsQ = useFiestaRiderShifts();
const shiftOptions = (shiftsQ.data ?? [])
.map((s) => ({ id: fnum((s as Record<string, unknown>).shiftid), label: fstr((s as Record<string, unknown>).shiftname) || `Shift ${fnum((s as Record<string, unknown>).shiftid)}` }))
.filter((s) => s.id > 0);
// Address autocomplete → discrete fields (or clear them when the field is emptied).
const handleAddressSelect = (r: AddressResult | null) => {
setNewUser((u) => ({
...u,
address: r?.address ?? '',
suburb: r?.suburb ?? '',
city: r?.city ?? '',
state: r?.state ?? '',
postcode: r?.postcode ?? '',
latitude: r?.latitude ?? '',
longitude: r?.longitude ?? '',
}));
};
// Live users mapped to display rows (rendered directly from the query).
const users = (usersQ.data ?? []).map((u, i) => {
const shift = fstr(u.shiftname).trim();
@@ -117,7 +166,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
email: fstr(u.email) || fstr(u.authname) || '—',
contact: fstr(u.contactno) || '—',
roleid: Number(u.roleid),
role: roleName(Number(u.roleid)),
role: roleName(Number(u.roleid)) === 'Manager' ? 'Admin' : roleName(Number(u.roleid)),
shift: shift && shift !== '-' ? shift : '—',
location: fstr(u.applocation) || fstr(u.city) || 'Coimbatore',
status: fstr(u.status) || 'Active',
@@ -132,30 +181,59 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
u.name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
u.contact.toLowerCase().includes(q);
const matchesRole = userRoleFilter === 'ALL' || u.roleid === userRoleFilter;
const matchesRole = userRoleFilter === 'ALL' || u.role === userRoleFilter;
return matchesSearch && matchesRole;
});
const roleOptions = Array.from(new Set(users.map((u) => u.roleid)));
const roleOptions = React.useMemo(() => {
return Array.from(new Set(users.map((u) => u.role)));
}, [users]);
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!newUser.firstname || !newUser.email || !newUser.contactno || !newUser.password) {
alert('Please provide first name, email, contact number, and a password.');
if (!newUser.firstname || !newUser.email || !newUser.contactno) {
alert('Please provide first name, contact number, and email.');
return;
}
if (!/^\d{10}$/.test(newUser.contactno.trim())) {
alert('Contact number must be exactly 10 digits.');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newUser.email.trim())) {
alert('Please enter a valid email address.');
return;
}
if (!Number(newUser.roleid)) {
alert('Please select a role.');
return;
}
if (!newUser.locationid) {
alert('Please select the store this user belongs to.');
return;
}
try {
await createUserMut.mutateAsync({
firstname: newUser.firstname,
lastname: newUser.lastname,
email: newUser.email,
contactno: newUser.contactno,
password: newUser.password,
roleid: Number(newUser.roleid),
configid: selectedRole?.configid ?? 15,
// Store binding — sent exactly as the backend expects so the user is tied
// to the chosen branch (tenantid + locationid + applocationid).
tenantid: tenantId,
locationid: newUser.locationid,
applocationid: newUser.applocationid || 1,
address: newUser.address,
suburb: newUser.suburb,
city: newUser.city,
state: newUser.state,
postcode: newUser.postcode,
latitude: newUser.latitude,
longitude: newUser.longitude,
shiftid: isRiderRole ? Number(newUser.shiftid) || 0 : 0,
});
setShowAddUserModal(false);
setNewUser({ firstname: '', lastname: '', email: '', contactno: '', password: '', roleid: defaultNewUserRole });
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.`);
} catch (err) {
alert(`Could not add team member: ${err instanceof Error ? err.message : 'Unknown error'}`);
@@ -199,25 +277,27 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
{/* Search & Filter Utility Bar */}
<div className="bg-slate-50/50 border border-slate-200/60 p-4 rounded-2xl flex flex-col md:flex-row gap-4 items-stretch md:items-center justify-between select-none">
<div className="relative w-full md:max-w-sm shrink-0">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-450">
<Search className="w-4.5 h-4.5" />
</span>
<input
type="text"
placeholder="Search team…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-9 py-2.5 bg-white border border-slate-200/80 rounded-xl text-sm font-medium text-slate-800 placeholder-slate-405 focus:outline-none focus:ring-4 focus:ring-purple-500/10 focus:border-purple-500 transition-all shadow-sm"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-450 hover:text-slate-700"
>
<X className="w-4 h-4" />
</button>
)}
<div className="relative w-full md:max-w-md shrink-0 group">
<div className="relative flex items-center bg-white border border-slate-200 rounded-xl transition-all duration-300 shadow-sm focus-within:ring-4 focus-within:ring-purple-150 focus-within:border-purple-600 hover:border-slate-300">
<span className="pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-purple-600 transition-colors">
<Search className="w-4.5 h-4.5" />
</span>
<input
type="text"
placeholder="Search team members by name, email, phone..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-3 pr-10 py-3 bg-transparent border-none text-xs font-semibold text-slate-800 placeholder-slate-400 focus:outline-none outline-none"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 active:scale-95 transition-all p-1 hover:bg-slate-100 rounded-lg"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Role filter capsules */}
@@ -232,17 +312,17 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
>
All Roles
</button>
{roleOptions.map((rid) => (
{roleOptions.map((roleNameStr) => (
<button
key={rid}
onClick={() => setUserRoleFilter(rid)}
key={roleNameStr}
onClick={() => setUserRoleFilter(roleNameStr)}
className={`px-3.5 py-2 rounded-xl text-xs uppercase tracking-wider font-extrabold transition-all duration-200 cursor-pointer border whitespace-nowrap ${
userRoleFilter === rid
userRoleFilter === roleNameStr
? 'bg-purple-600 text-white border-purple-600 shadow-sm'
: 'bg-white text-slate-600 border-slate-200/80 hover:text-slate-800 hover:bg-slate-100'
}`}
>
{roleName(rid)}
{roleNameStr}
</button>
))}
</div>
@@ -352,34 +432,42 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</p>
<div className="space-y-4">
{/* Name Fields */}
<div className="grid grid-cols-2 gap-sm">
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">FIRST NAME (*)</label>
{/* Firstname */}
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">FIRSTNAME (*)</label>
<input
type="text"
placeholder="e.g. Harini"
value={newUser.firstname}
onChange={(e) => setNewUser({ ...newUser, firstname: e.target.value })}
className="w-full border border-slate-200 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
required
/>
</div>
{/* Contactno */}
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">CONTACTNO (*)</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Phone size={14} />
</span>
<input
type="text"
placeholder="e.g. Harini"
value={newUser.firstname}
onChange={(e) => setNewUser({ ...newUser, firstname: e.target.value })}
className="w-full border border-slate-200 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
inputMode="numeric"
maxLength={10}
placeholder="e.g. 9988776655"
value={newUser.contactno}
onChange={(e) => setNewUser({ ...newUser, contactno: e.target.value.replace(/\D/g, '') })}
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
required
/>
</div>
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">LAST NAME</label>
<input
type="text"
placeholder="e.g. Rajan"
value={newUser.lastname}
onChange={(e) => setNewUser({ ...newUser, lastname: e.target.value })}
className="w-full border border-slate-200 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
/>
</div>
</div>
{/* Email & Contact */}
{/* Email */}
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">EMAIL ADDRESS (*)</label>
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">EMAIL (*)</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Mail size={14} />
@@ -395,23 +483,6 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</div>
</div>
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">CONTACT NUMBER (*)</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Phone size={14} />
</span>
<input
type="text"
placeholder="e.g. 9988776655"
value={newUser.contactno}
onChange={(e) => setNewUser({ ...newUser, contactno: e.target.value })}
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
required
/>
</div>
</div>
{/* Interactive Role Buttons instead of standard select */}
<div className="space-y-2">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">SELECT ACCOUNT ROLE (*)</label>
@@ -441,23 +512,70 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</div>
</div>
{/* Temporary Password */}
{/* Store / branch — binds the user to a specific outlet (tenantid + locationid + applocationid) */}
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">TEMPORARY PASSWORD (*)</label>
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">STORE / BRANCH (*)</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Lock size={14} />
<Store size={14} />
</span>
<input
type="text"
placeholder="Set password credentials"
value={newUser.password}
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold font-mono text-sm shadow-sm"
required
/>
<select
value={newUser.locationid}
onChange={(e) => {
const lid = Number(e.target.value);
const s = storeOptions.find((o) => o.locationid === lid);
setNewUser({ ...newUser, locationid: lid, applocationid: s?.applocationid ?? 0 });
}}
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm cursor-pointer"
>
<option value={0}>{locationsQ.isLoading ? 'Loading stores…' : storeOptions.length ? 'Select a store…' : 'No stores found'}</option>
{storeOptions.map((s) => (
<option key={s.locationid} value={s.locationid}>{s.name}{s.address ? `${s.address}` : ''}</option>
))}
</select>
</div>
</div>
{/* Rider shift — only when a rider role is selected */}
{isRiderRole && (
<div className="space-y-1.5 animate-in slide-in-from-top duration-250">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">RIDER SHIFT</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Clock size={14} />
</span>
<select
value={newUser.shiftid}
onChange={(e) => setNewUser({ ...newUser, shiftid: Number(e.target.value) })}
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm cursor-pointer"
>
<option value={0}>{shiftsQ.isLoading ? 'Loading shifts…' : shiftOptions.length ? 'Select rider shift…' : 'No shifts available'}</option>
{shiftOptions.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
</select>
</div>
</div>
)}
{/* Address — keyless autocomplete that fills suburb / city / state / postcode */}
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">ADDRESS</label>
<AddressAutocomplete value={newUser.address} onSelect={handleAddressSelect} placeholder="Search address…" />
</div>
<div className="grid grid-cols-2 gap-sm">
{(['suburb', 'city', 'state', 'postcode'] as const).map((key) => (
<div className="space-y-1.5" key={key}>
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">{key.toUpperCase()}</label>
<input
type="text"
value={newUser[key]}
onChange={(e) => setNewUser({ ...newUser, [key]: e.target.value })}
className="w-full border border-slate-200 rounded-xl p-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
/>
</div>
))}
</div>
</div>
</div>