Files
daily_merchant_web/src/components/UsersPanel.tsx
2026-07-16 17:05:06 +05:30

755 lines
38 KiB
TypeScript

/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Users & Access — tenant staff directory with role filtering and user creation.
* Rendered as a tab inside SettingsView.
* Self-contained: search box, role filter, live query, and Add User modal.
*/
import React, { useState, useEffect } from 'react';
import {
Users,
Search,
X,
Plus,
ShieldAlert,
Shield,
User,
Mail,
Phone,
MapPin,
UserCheck,
Check,
SlidersHorizontal,
Coins,
Store,
Bike,
Clock
} from 'lucide-react';
import { useFiestaUsers, useFiestaCreateUser, useFiestaUpdateUser, 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;
/** Pre-selected role in the Add User dialog (from workspace preferences). */
defaultNewUserRole?: number;
}
const USER_AVATARS = [
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=150&q=80',
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=150&q=80',
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=150&q=80',
];
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: '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' },
5: { bg: 'bg-purple-50/75', text: 'text-purple-700', border: 'border-purple-100', label: 'Rider' },
6: { bg: 'bg-indigo-50/75', text: 'text-indigo-700', border: 'border-indigo-100', label: 'Cashier' },
};
/** Cosmetic icon + blurb per role id, used to keep the add-user role cards styled. */
const ROLE_META: Record<number, { icon: typeof ShieldAlert; desc: string }> = {
1: { icon: ShieldAlert, desc: 'Full business access' },
2: { icon: Shield, desc: 'Operations control' },
3: { icon: SlidersHorizontal, desc: 'Manage store settings' },
4: { icon: User, desc: 'Standard staff duties' },
6: { icon: Coins, desc: 'Checkout & registers' },
};
/** 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: '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 },
];
export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) {
const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 });
const createUserMut = useFiestaCreateUser();
const updateUserMut = useFiestaUpdateUser();
const rolesQ = useAppRoles();
const [assignUserId, setAssignUserId] = useState<number | null>(null);
const [assignLocationId, setAssignLocationId] = useState<number>(0);
const [assignRoleId, setAssignRoleId] = useState<number>(4);
const handleToggleAssign = async (userid: number, currentRoleId: number) => {
try {
if (currentRoleId <= 0) {
// Open the assign modal
setAssignUserId(userid);
if (storeOptions.length === 1) {
setAssignLocationId(storeOptions[0].locationid);
}
} else {
// Unassign immediately. We use -1 because the Go backend ignores 0 (zero value) during updates.
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'}`);
}
};
// 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(() => {
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<string | 'ALL'>('ALL');
const [showAddUserModal, setShowAddUserModal] = useState(false);
const [newUser, setNewUser] = useState({
firstname: '',
email: '',
contactno: '',
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 }));
}
if (!assignLocationId && storeOptions.length === 1) {
setAssignLocationId(storeOptions[0].locationid);
}
}, [storeOptions.length, newUser.locationid, assignLocationId]);
// 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();
return {
userid: Number(u.userid),
name:
fstr(u.fullname).trim() ||
`${fstr(u.firstname)} ${fstr(u.lastname)}`.trim() ||
fstr(u.authname) ||
'User',
email: fstr(u.email) || fstr(u.authname) || '—',
contact: fstr(u.contactno) || '—',
roleid: 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',
avatar: USER_AVATARS[i % USER_AVATARS.length],
};
});
const filteredUsers = users.filter((u) => {
const q = search.toLowerCase();
const matchesSearch =
!q ||
u.name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
u.contact.toLowerCase().includes(q);
const matchesRole = userRoleFilter === 'ALL' || u.role === userRoleFilter;
return matchesSearch && matchesRole;
});
const roleOptions = React.useMemo(() => {
const roles = new Set(users.map((u) => u.role));
roles.add('Admin');
roles.add('Rider');
return Array.from(roles);
}, [users]);
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
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,
email: newUser.email,
contactno: newUser.contactno,
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: '', 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'}`);
}
};
return (
<div className="space-y-lg animate-in fade-in duration-300 text-sm">
{/* Header section */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-md pb-4 border-b border-slate-100">
<div>
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest block font-sans">Users & Access</span>
<h2 className="text-xl font-bold text-slate-900 mt-1">Manage Store Team</h2>
<p className="text-slate-500 text-xs mt-1.5 leading-relaxed">
Manage your store team, access roles, and contact details.
</p>
<div className="mt-3">
{usersQ.isLoading ? (
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-zinc-400 uppercase tracking-wide">
<span className="w-2 h-2 rounded-full bg-zinc-300 animate-pulse" /> Loading team list
</span>
) : usersQ.isError ? (
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-rose-600 uppercase tracking-wide">
<span className="w-2 h-2 rounded-full bg-rose-500" /> Connection issue
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-emerald-650 uppercase tracking-wide">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" /> Active · {users.length} Team Members
</span>
)}
</div>
</div>
<button
onClick={() => setShowAddUserModal(true)}
className="bg-purple-650 hover:bg-purple-755 text-white px-5 py-3 rounded-xl text-sm font-bold uppercase tracking-wider flex items-center justify-center gap-2 cursor-pointer shadow-sm active:scale-95 transition-all border-none"
>
<Plus size={16} /> Add Team Member
</button>
</div>
{/* Search & Filter Utility Bar */}
<div className="flex flex-col md:flex-row gap-6 items-stretch md:items-center justify-between mb-4">
<div className="relative w-full md:max-w-sm shrink-0">
<div className="relative flex w-full items-center border-b-2 border-slate-200 focus-within:border-purple-600 transition-colors duration-300">
<span className="pl-1 flex items-center pointer-events-none text-slate-400">
<Search size={16} strokeWidth={3} />
</span>
<input
type="text"
placeholder="Search team members..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-3 pr-10 py-2.5 bg-transparent border-none text-sm font-semibold text-slate-800 placeholder-slate-400 focus:outline-none outline-none"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-700 active:scale-95 transition-all p-1.5 flex items-center justify-center"
>
<X size={14} strokeWidth={3} />
</button>
)}
</div>
</div>
{/* Role filter capsules */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 md:pb-0 scrollbar-none select-none">
<button
onClick={() => setUserRoleFilter('ALL')}
className={`px-3.5 py-2 rounded-xl text-xs uppercase tracking-wider font-extrabold transition-all duration-200 cursor-pointer border ${
userRoleFilter === 'ALL'
? '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'
}`}
>
All Roles
</button>
{roleOptions.map((roleNameStr) => (
<button
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 === 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'
}`}
>
{roleNameStr}
</button>
))}
</div>
</div>
{/* Directory Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredUsers.length === 0 ? (
<div className="col-span-full bg-white border border-slate-200/60 rounded-2xl p-12 text-center text-slate-500 font-bold text-base">
{usersQ.isLoading ? 'Loading team list…' : 'No matches found in team list.'}
</div>
) : (
filteredUsers.map((u) => {
const roleInfo = ROLE_THEMES[u.roleid] || { bg: 'bg-slate-55', text: 'text-slate-700', border: 'border-slate-100', label: u.role };
return (
<div key={u.userid} className="bg-white border border-slate-200/60 hover:border-purple-300 hover:shadow-md rounded-2xl p-4 transition-all duration-300 flex flex-col justify-between group relative overflow-hidden">
{/* Background aura gradient effect on hover */}
<div className="absolute top-0 right-0 w-24 h-24 bg-purple-500/5 rounded-full blur-xl -mr-6 -mt-6 pointer-events-none transition-all group-hover:bg-purple-500/10" />
<div>
<div className="flex items-start gap-3 relative z-10">
{/* User Avatar with status indicator ring */}
<div className="relative shrink-0 select-none">
<img
src={u.avatar}
alt={u.name}
referrerPolicy="no-referrer"
className="w-10 h-10 object-cover rounded-[10px] border border-slate-200"
/>
<span className={`absolute -bottom-1 -right-1 w-3.5 h-3.5 rounded-full border-[2.5px] border-white ${
u.status.toLowerCase() === 'active' ? 'bg-emerald-500' : 'bg-slate-350'
}`} />
</div>
<div className="min-w-0 flex-1">
<h4 className="font-bold text-slate-800 text-sm truncate group-hover:text-purple-950 transition-colors leading-tight">{u.name}</h4>
<p className="text-[11px] text-slate-450 mt-1 truncate font-medium">{u.email}</p>
</div>
</div>
{/* Metadata fields */}
<div className="mt-4 space-y-2 border-t border-slate-100/75 pt-3 text-[11px]">
<div className="flex items-center gap-2 text-slate-600 font-medium">
<Phone size={13} className="text-slate-400 shrink-0" />
<span className="font-mono">{u.contact}</span>
</div>
<div className="flex items-center gap-2 text-slate-600 font-medium">
<MapPin size={13} className="text-slate-400 shrink-0" />
<span className="truncate">{u.location}</span>
</div>
{u.shift && u.shift !== '—' && (
<div className="flex items-center gap-2 text-slate-600 font-medium">
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-extrabold">Shift</span>
<span className="font-bold text-slate-700 bg-slate-50 px-2 py-0.5 rounded-md border border-slate-150">{u.shift}</span>
</div>
)}
</div>
</div>
<div className="mt-4 pt-3 border-t border-slate-100/75 flex justify-between items-center select-none">
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-[8px] text-[10px] font-extrabold uppercase border ${roleInfo.bg} ${roleInfo.text} ${roleInfo.border}`}>
{u.roleid === 1 && <ShieldAlert size={10} />}
{u.roleid === 2 && <Shield size={10} />}
{u.roleid === 3 && <SlidersHorizontal size={10} />}
{u.roleid === 4 && <User size={10} />}
{u.roleid === 5 && <Bike size={10} />}
{u.roleid === 6 && <Coins size={10} />}
{roleInfo.label}
</span>
{u.roleid !== 1 && (
<button
onClick={() => handleToggleAssign(u.userid, u.roleid)}
disabled={updateUserMut.isPending}
className={`px-2 py-0.5 rounded-[8px] text-[9px] font-extrabold uppercase transition-colors cursor-pointer border ${
u.roleid <= 0
? 'bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100'
: 'bg-slate-50 text-slate-500 border-slate-200 hover:bg-slate-100 hover:text-slate-700'
} ${updateUserMut.isPending ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{u.roleid <= 0 ? 'Assign' : 'Unassign'}
</button>
)}
</div>
<span className="text-[10px] font-mono text-slate-400 font-bold">#{u.userid}</span>
</div>
</div>
);
})
)}
</div>
{/* CREATE NEW USER MODAL */}
{showAddUserModal && (
<div
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md select-none"
onClick={(e) => { if (e.target === e.currentTarget) setShowAddUserModal(false); }}
>
<div className="bg-white border border-slate-200/80 w-full max-w-[30rem] max-h-[90vh] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-sm font-sans cursor-default">
{/* Modal Header */}
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center shrink-0">
<h4 className="font-bold text-slate-900 flex items-center gap-2.5 text-base">
<div className="w-8 h-8 rounded-lg bg-purple-50 text-purple-650 flex items-center justify-center">
<Users size={16} />
</div>
Add Team Member
</h4>
<button
onClick={() => setShowAddUserModal(false)}
className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 cursor-pointer transition-colors"
>
<X size={18} />
</button>
</div>
{/* Modal Form */}
<form onSubmit={handleCreateUser} className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div className="p-6 space-y-md overflow-y-auto flex-1 scrollbar-thin">
<p className="text-slate-505 leading-relaxed text-xs">
Add a new member to your store team. This will create their account and sync it to the list.
</p>
<div className="space-y-4">
{/* 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"
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>
{/* Email */}
<div className="space-y-1.5">
<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} />
</span>
<input
type="email"
placeholder="e.g. harini@store.com"
value={newUser.email}
onChange={(e) => setNewUser({ ...newUser, email: 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>
<div className="grid grid-cols-2 gap-2.5">
{roleChoices.map((r) => {
const isSelected = newUser.roleid === r.id;
const Icon = r.icon;
return (
<button
key={r.id}
type="button"
onClick={() => setNewUser({ ...newUser, roleid: r.id })}
className={`p-3 rounded-xl border text-left transition-all cursor-pointer flex gap-2.5 items-start ${
isSelected
? 'bg-purple-50 border-purple-500 ring-2 ring-purple-500/10'
: 'bg-slate-50/40 hover:bg-slate-50 border-slate-200/80'
}`}
>
<Icon size={14} className={`shrink-0 mt-0.5 ${isSelected ? 'text-purple-650' : 'text-slate-450'}`} />
<div className="min-w-0">
<span className={`font-bold text-xs block leading-tight ${isSelected ? 'text-purple-950' : 'text-slate-800'}`}>{r.label}</span>
<span className="text-[10px] text-slate-455 leading-tight block mt-1 font-medium">{r.desc}</span>
</div>
</button>
);
})}
</div>
</div>
{/* 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]">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">
<Store size={14} />
</span>
<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>
{/* Modal Footer */}
<div className="p-5 border-t border-slate-100 flex justify-end gap-sm bg-slate-50/50 shrink-0">
<button
type="button"
onClick={() => setShowAddUserModal(false)}
className="px-5 py-2.5 border border-slate-200 hover:bg-slate-100 rounded-xl font-bold text-slate-500 hover:text-slate-700 cursor-pointer active:scale-95 transition-all text-sm"
>
Cancel
</button>
<button
type="submit"
disabled={createUserMut.isPending}
className="px-6 py-2.5 bg-purple-650 hover:bg-purple-755 text-white rounded-xl font-bold cursor-pointer shadow-sm disabled:opacity-60 disabled:cursor-not-allowed active:scale-95 transition-all flex items-center gap-1.5 border-none text-sm"
>
{createUserMut.isPending ? (
<>
<span className="w-2 h-2 rounded-full bg-white animate-pulse" />
Creating
</>
) : (
<>
<Check size={14} />
Add Member
</>
)}
</button>
</div>
</form>
</div>
</div>
)}
{/* ASSIGN USER MODAL */}
{assignUserId !== null && (
<div
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md select-none"
onClick={(e) => { if (e.target === e.currentTarget) setAssignUserId(null); }}
>
<div className="bg-white border border-slate-200/80 w-full max-w-[28rem] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-sm font-sans cursor-default">
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center shrink-0">
<h4 className="font-bold text-slate-900 flex items-center gap-2.5 text-base">
<div className="w-8 h-8 rounded-lg bg-emerald-50 text-emerald-650 flex items-center justify-center">
<UserCheck size={16} />
</div>
Assign Team Member
</h4>
<button
onClick={() => setAssignUserId(null)}
className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 cursor-pointer transition-colors"
>
<X size={18} />
</button>
</div>
<form onSubmit={async (e) => {
e.preventDefault();
const store = storeOptions.find(s => s.locationid === assignLocationId);
try {
await updateUserMut.mutateAsync({
userid: assignUserId,
roleid: assignRoleId,
locationid: assignLocationId,
applocationid: store?.applocationid ?? 1
});
setAssignUserId(null);
} catch (err) {
alert(`Could not assign user: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}} className="p-6 space-y-5">
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">ROLE (*)</label>
<div className="relative">
<select
value={assignRoleId}
onChange={(e) => setAssignRoleId(Number(e.target.value))}
className="w-full border border-slate-200 rounded-xl px-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"
>
{roleChoices.map((r) => (
<option key={r.id} value={r.id}>{r.label}</option>
))}
</select>
</div>
</div>
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">STORE / LOCATION (*)</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Store size={14} />
</span>
<select
value={assignLocationId}
onChange={(e) => setAssignLocationId(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"
required
>
<option value={0} disabled>{locationsQ.isLoading ? 'Loading stores…' : 'Select a store…'}</option>
{storeOptions.map((s) => (
<option key={s.locationid} value={s.locationid}>{s.name}{s.address ? `${s.address}` : ''}</option>
))}
</select>
</div>
</div>
<div className="pt-4 border-t border-slate-100 flex justify-end gap-sm mt-6">
<button
type="button"
onClick={() => setAssignUserId(null)}
className="px-5 py-2.5 border border-slate-200 hover:bg-slate-100 rounded-xl font-bold text-slate-500 cursor-pointer active:scale-95 transition-all text-sm"
>
Cancel
</button>
<button
type="submit"
disabled={updateUserMut.isPending || !assignLocationId}
className="px-6 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold cursor-pointer shadow-sm disabled:opacity-60 disabled:cursor-not-allowed active:scale-95 transition-all flex items-center gap-1.5 border-none text-sm"
>
{updateUserMut.isPending ? 'Assigning…' : 'Assign User'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}