755 lines
38 KiB
TypeScript
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>
|
|
);
|
|
}
|