import React, { useEffect, useMemo, useRef, useState } from "react"; import { Search, Plus, Download, Upload, Edit, MoreHorizontal, ChevronDown, ChevronRight, Trash2, FileText, MapPin, Shield, Bell, Check, X } from "lucide-react"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../ui/table"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "../ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "../ui/select"; import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "../ui/pagination"; import { Label } from "../ui/label"; import { Switch } from "../ui/switch"; import { Badge } from "../ui/badge"; import { Checkbox } from "../ui/checkbox"; import { ScrollArea } from "../ui/scroll-area"; import { cn } from "../ui/utils"; import { toast } from "sonner"; import { getRbacMenuTree } from "../../services/systemMenuService"; import { deleteRoleMenus, setRoleMenus } from "../../services/rbacRoleMenuService"; import type { RbacMenuTreeNode } from "../../types/systemMenu"; import { getRoles } from "../../services/roleService"; import { createRbacRole, deleteRbacRole, getRbacRoleMenuIds, updateRbacRole } from "../../services/rbacRoleService"; import type { RoleDto } from "../../types/role"; import { getLocations } from "../../services/locationService"; import { createTeamMember, deleteTeamMember, getTeamMemberById, getTeamMembers, updateTeamMember, } from "../../services/teamMemberService"; import type { LocationDto } from "../../types/location"; import type { TeamMemberCreateInput, TeamMemberDto, TeamMemberUpdateInput } from "../../types/teamMember"; // --- Mock Data --- const MOCK_LOCATIONS = [ { id: '1', name: 'Downtown Store (101)' }, { id: '2', name: 'Uptown Store (102)' }, { id: '3', name: 'Airport Kiosk (201)' }, { id: '4', name: 'Mall Outlet (305)' }, ]; const MOCK_ROLES = [ { id: 'r1', name: 'Partner Admin', permissions: ['all'], notifications: ['system_updates', 'billing'] }, { id: 'r2', name: 'Group Admin', permissions: ['manage_users', 'manage_products', 'view_reports'], notifications: ['new_users'] }, { id: 'r3', name: 'Manager', permissions: ['manage_store', 'view_reports', 'manage_inventory'], notifications: ['label_expiry', 'low_stock'] }, { id: 'r4', name: 'Team Member', permissions: ['view_tasks', 'print_labels'], notifications: ['task_assignment'] } ]; const MOCK_PARTNERS = [ { id: 'p1', name: 'Global Foods Inc.', status: 'active', contact: 'admin@globalfoods.com', phone: '+1 (555) 100-2000' }, { id: 'p2', name: 'Local Eateries Co.', status: 'active', contact: 'support@localeateries.com', phone: '+1 (555) 200-3000' }, ]; const MOCK_GROUPS = [ { id: 'g1', name: 'West Coast Region', partner: 'Global Foods Inc.', status: 'active' }, { id: 'g2', name: 'East Coast Region', partner: 'Global Foods Inc.', status: 'inactive' }, ]; const MOCK_MEMBERS = [ { id: 'm1', name: 'Alice Johnson', role: 'Manager', locations: ['Downtown Store (101)', 'Uptown Store (102)'], email: 'alice@example.com', phone: '+1 (555) 111-2222', status: 'active' }, { id: 'm2', name: 'Bob Smith', role: 'Team Member', locations: ['Airport Kiosk (201)'], email: 'bob@example.com', phone: '+1 (555) 222-3333', status: 'active' }, { id: 'm3', name: 'Charlie Brown', role: 'Team Member', locations: ['Downtown Store (101)'], email: 'charlie@example.com', phone: '+1 (555) 333-4444', status: 'inactive' }, ]; // --- Types --- type ViewTab = 'Roles' | 'Partner' | 'Group' | 'Team Member'; export function PeopleView() { const [activeTab, setActiveTab] = useState('Roles'); // Data States const [roles, setRoles] = useState([]); const [roleTotal, setRoleTotal] = useState(0); const [rolesLoading, setRolesLoading] = useState(false); const [roleRefreshSeq, setRoleRefreshSeq] = useState(0); const [rolePageIndex, setRolePageIndex] = useState(1); const [rolePageSize, setRolePageSize] = useState(10); const roleTotalPages = Math.max(1, Math.ceil(roleTotal / rolePageSize)); const rolesAbortRef = useRef(null); const [roleKeyword, setRoleKeyword] = useState(""); const roleKeywordTimerRef = useRef(null); const [debouncedRoleKeyword, setDebouncedRoleKeyword] = useState(""); const [partners, setPartners] = useState(MOCK_PARTNERS); const [groups, setGroups] = useState(MOCK_GROUPS); const [members, setMembers] = useState([]); const [membersLoading, setMembersLoading] = useState(false); const [memberTotal, setMemberTotal] = useState(0); const [memberRefreshSeq, setMemberRefreshSeq] = useState(0); const [memberPageIndex, setMemberPageIndex] = useState(1); const [memberPageSize, setMemberPageSize] = useState(10); const memberTotalPages = Math.max(1, Math.ceil(memberTotal / memberPageSize)); const membersAbortRef = useRef(null); const [memberKeyword, setMemberKeyword] = useState(""); const memberKeywordTimerRef = useRef(null); const [debouncedMemberKeyword, setDebouncedMemberKeyword] = useState(""); const [editingMember, setEditingMember] = useState(null); const [isDeleteMemberDialogOpen, setIsDeleteMemberDialogOpen] = useState(false); const [deletingMember, setDeletingMember] = useState(null); // Dialog States const [isRoleDialogOpen, setIsRoleDialogOpen] = useState(false); const [editingRole, setEditingRole] = useState(null); const [isRoleMenuDialogOpen, setIsRoleMenuDialogOpen] = useState(false); const [menuRole, setMenuRole] = useState(null); const [isDeleteRoleDialogOpen, setIsDeleteRoleDialogOpen] = useState(false); const [deletingRole, setDeletingRole] = useState(null); const [isPartnerDialogOpen, setIsPartnerDialogOpen] = useState(false); const [isGroupDialogOpen, setIsGroupDialogOpen] = useState(false); const [isMemberDialogOpen, setIsMemberDialogOpen] = useState(false); // Handlers const handleExportPdf = () => { alert(`Exporting ${activeTab} list to PDF...`); }; useEffect(() => { if (roleKeywordTimerRef.current) window.clearTimeout(roleKeywordTimerRef.current); roleKeywordTimerRef.current = window.setTimeout(() => setDebouncedRoleKeyword(roleKeyword.trim()), 300); return () => { if (roleKeywordTimerRef.current) window.clearTimeout(roleKeywordTimerRef.current); }; }, [roleKeyword]); useEffect(() => { if (memberKeywordTimerRef.current) window.clearTimeout(memberKeywordTimerRef.current); memberKeywordTimerRef.current = window.setTimeout(() => setDebouncedMemberKeyword(memberKeyword.trim()), 300); return () => { if (memberKeywordTimerRef.current) window.clearTimeout(memberKeywordTimerRef.current); }; }, [memberKeyword]); useEffect(() => { setRolePageIndex(1); }, [debouncedRoleKeyword, rolePageSize]); useEffect(() => { setMemberPageIndex(1); }, [debouncedMemberKeyword, memberPageSize]); useEffect(() => { if (activeTab !== "Roles") return; const run = async () => { rolesAbortRef.current?.abort(); const ac = new AbortController(); rolesAbortRef.current = ac; setRolesLoading(true); try { const res = await getRoles( { skipCount: Math.max(1, rolePageIndex), maxResultCount: rolePageSize, roleName: debouncedRoleKeyword || undefined, }, ac.signal, ); setRoles(res.items ?? []); setRoleTotal(res.totalCount ?? 0); } catch (e: any) { if (e?.name === "AbortError") return; toast.error("Failed to load roles.", { description: e?.message ? String(e.message) : "Please try again.", }); setRoles([]); setRoleTotal(0); } finally { setRolesLoading(false); } }; run(); return () => rolesAbortRef.current?.abort(); }, [activeTab, debouncedRoleKeyword, rolePageIndex, rolePageSize, roleRefreshSeq]); useEffect(() => { if (activeTab !== "Team Member") return; const run = async () => { membersAbortRef.current?.abort(); const ac = new AbortController(); membersAbortRef.current = ac; setMembersLoading(true); try { const res = await getTeamMembers( { skipCount: Math.max(1, memberPageIndex), maxResultCount: memberPageSize, keyword: debouncedMemberKeyword || undefined, }, ac.signal, ); setMembers(res.items ?? []); setMemberTotal(res.totalCount ?? 0); } catch (e: any) { if (e?.name === "AbortError") return; toast.error("Failed to load team members.", { description: e?.message ? String(e.message) : "Please try again.", }); setMembers([]); setMemberTotal(0); } finally { setMembersLoading(false); } }; run(); return () => membersAbortRef.current?.abort(); }, [activeTab, debouncedMemberKeyword, memberPageIndex, memberPageSize, memberRefreshSeq]); const openCreateDialog = () => { switch (activeTab) { case 'Roles': setEditingRole(null); setIsRoleDialogOpen(true); break; case 'Partner': setIsPartnerDialogOpen(true); break; case 'Group': setIsGroupDialogOpen(true); break; case 'Team Member': setEditingMember(null); setIsMemberDialogOpen(true); break; } }; const renderToolbar = () => { const canBulkOps = activeTab === 'Team Member'; return (
{/* Search + Actions - one row, style consistent with Labels / Location Manager */}
{ if (activeTab === "Roles") setRoleKeyword(e.target.value); if (activeTab === "Team Member") setMemberKeyword(e.target.value); }} style={{ height: 40, boxSizing: 'border-box' }} className="border border-gray-300 rounded-md w-40 shrink-0 bg-white placeholder:text-gray-500" />
{canBulkOps && ( <> )}
{/* Tabs - underline spans full width */}
{['Roles', 'Partner', 'Group', 'Team Member'].map((tab) => ( ))}
); }; const renderContent = () => { switch (activeTab) { case 'Roles': return (
Role Name Role Code Status Order Actions {roles.length === 0 ? ( {rolesLoading ? "Loading..." : "No data"} ) : ( roles.map((r) => ( {r.roleName ?? "N/A"} {r.roleCode ?? "N/A"} {r.state ? "Active" : "Inactive"} {r.orderNum ?? "N/A"} )) )}
Showing {roleTotal === 0 ? 0 : (rolePageIndex - 1) * rolePageSize + 1}- {Math.min(rolePageIndex * rolePageSize, roleTotal)} of {roleTotal}
{ e.preventDefault(); setRolePageIndex((p) => Math.max(1, p - 1)); }} aria-disabled={rolePageIndex <= 1} className={rolePageIndex <= 1 ? "pointer-events-none opacity-50" : ""} /> e.preventDefault()}> Page {rolePageIndex} / {roleTotalPages} { e.preventDefault(); setRolePageIndex((p) => Math.min(roleTotalPages, p + 1)); }} aria-disabled={rolePageIndex >= roleTotalPages} className={rolePageIndex >= roleTotalPages ? "pointer-events-none opacity-50" : ""} />
); case 'Partner': return ( Partner Name Contact Phone Status Actions {partners.map(p => ( {p.name} {p.contact} {p.phone} {p.status} ))}
); case 'Group': return ( Group Name Parent Partner Status Actions {groups.map(g => ( {g.name} {g.partner} {g.status} ))}
); case 'Team Member': return ( <> Name Email Phone Role Assigned Locations Status Actions {membersLoading ? ( Loading... ) : members.length === 0 ? ( No results. ) : ( members.map((m) => ( {m.fullName ?? m.userName ?? "N/A"} {m.email ?? "N/A"} {m.phone ?? "N/A"} {m.roleName ?? m.roleId ?? "N/A"}
{(m.locations?.length ? m.locations : m.locationIds ?? []).map((loc) => (
{loc}
))} {(!m.locations?.length && !(m.locationIds?.length ?? 0)) && (
None
)}
{m.state ? "Active" : "Inactive"}
)) )}
Showing {memberTotal === 0 ? 0 : (memberPageIndex - 1) * memberPageSize + 1}- {Math.min(memberPageIndex * memberPageSize, memberTotal)} of {memberTotal}
{ e.preventDefault(); setMemberPageIndex((p) => Math.max(1, p - 1)); }} aria-disabled={memberPageIndex <= 1} className={memberPageIndex <= 1 ? "pointer-events-none opacity-50" : ""} /> e.preventDefault()}> Page {memberPageIndex} / {memberTotalPages} { e.preventDefault(); setMemberPageIndex((p) => Math.min(memberTotalPages, p + 1)); }} aria-disabled={memberPageIndex >= memberTotalPages} className={memberPageIndex >= memberTotalPages ? "pointer-events-none opacity-50" : ""} />
); } }; return (
{renderToolbar()}
{renderContent()}
{/* --- Dialogs --- */} { setIsRoleDialogOpen(open); if (!open) setEditingRole(null); }} onSaved={() => { setRolePageIndex(1); setRoleRefreshSeq((x) => x + 1); }} /> { setIsRoleMenuDialogOpen(open); if (!open) setMenuRole(null); }} /> { setIsDeleteRoleDialogOpen(open); if (!open) setDeletingRole(null); }} onDeleted={() => setRoleRefreshSeq((x) => x + 1)} /> { setIsMemberDialogOpen(open); if (!open) setEditingMember(null); }} onSaved={() => { setMemberPageIndex(1); setMemberRefreshSeq((x) => x + 1); }} /> { setIsDeleteMemberDialogOpen(open); if (!open) setDeletingMember(null); }} onDeleted={() => { setMemberRefreshSeq((x) => x + 1); }} />
); } // --- Sub-Components (Dialogs) --- function RoleDialog({ open, role, onOpenChange, onSaved, }: { open: boolean; role: RoleDto | null; onOpenChange: (open: boolean) => void; onSaved: () => void; }) { const isEdit = !!role?.id; const [submitting, setSubmitting] = useState(false); const [roleName, setRoleName] = useState(""); const [roleCode, setRoleCode] = useState(""); const [remark, setRemark] = useState(""); const [orderNum, setOrderNum] = useState(""); const [state, setState] = useState(true); useEffect(() => { if (!open) return; setSubmitting(false); setRoleName(role?.roleName ?? ""); setRoleCode(role?.roleCode ?? ""); setRemark(role?.remark ?? ""); setOrderNum(role?.orderNum === null || role?.orderNum === undefined ? "" : String(role.orderNum)); setState(role?.state ?? true); }, [open, role]); const canSubmit = useMemo(() => { return Boolean(roleName.trim() && roleCode.trim()); }, [roleName, roleCode]); const toIntOrNullLocal = (v: string): number | null => { const s = v.trim(); if (!s) return null; const n = Number.parseInt(s, 10); return Number.isFinite(n) ? n : null; }; const submit = async () => { console.log("submit", role); if (!canSubmit) { toast.error("Please fill in required fields.", { description: "Role Name and Role Code are required.", }); return; } setSubmitting(true); try { const payload = { roleName: roleName.trim(), roleCode: roleCode.trim(), remark: remark.trim() ? remark.trim() : null, state: !!state, orderNum: toIntOrNullLocal(orderNum), }; if (isEdit && role?.id) { await updateRbacRole(role.id, payload); toast.success("Role updated.", { description: "Role fields have been saved successfully." }); } else { await createRbacRole(payload); toast.success("Role created.", { description: "A new role has been created successfully." }); } onOpenChange(false); onSaved(); } catch (e: any) { toast.error(isEdit ? "Failed to update role." : "Failed to create role.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; return ( {isEdit ? "Edit Role" : "Create Role"} {isEdit ? "Update role fields and save changes." : "Fill out the form to create a new role."}
setRoleName(e.target.value)} placeholder="e.g. Inventory Specialist" />
setRoleCode(e.target.value)} placeholder="e.g. inventory_specialist" />
setRemark(e.target.value)} placeholder="Optional" />
setOrderNum(e.target.value)} placeholder="e.g. 10" />
Enabled
); } function RoleMenuPermissionsDialog({ open, role, onOpenChange, }: { open: boolean; role: RoleDto | null; onOpenChange: (open: boolean) => void; }) { const roleId = role?.id ?? ""; const roleName = role?.roleName ?? ""; const [submitting, setSubmitting] = useState(false); const [menuTree, setMenuTree] = useState([]); const [menuExpandedIds, setMenuExpandedIds] = useState>(new Set()); const [selectedIds, setSelectedIds] = useState>(new Set()); const [loadingMenus, setLoadingMenus] = useState(false); const abortRef = useRef(null); const [menuKeyword, setMenuKeyword] = useState(""); const menuKeywordTimerRef = useRef(null); const [debouncedMenuKeyword, setDebouncedMenuKeyword] = useState(""); useEffect(() => { if (menuKeywordTimerRef.current) window.clearTimeout(menuKeywordTimerRef.current); menuKeywordTimerRef.current = window.setTimeout(() => setDebouncedMenuKeyword(menuKeyword.trim()), 300); return () => { if (menuKeywordTimerRef.current) window.clearTimeout(menuKeywordTimerRef.current); }; }, [menuKeyword]); useEffect(() => { if (!open) return; setSubmitting(false); setSelectedIds(new Set()); setMenuExpandedIds(new Set()); const run = async () => { abortRef.current?.abort(); const ac = new AbortController(); abortRef.current = ac; setLoadingMenus(true); try { const tree = await getRbacMenuTree(ac.signal); setMenuTree(tree ?? []); if (roleId) { const checked = await getRbacRoleMenuIds(roleId, ac.signal); setSelectedIds(new Set(checked)); } } catch (e: any) { if (e?.name === "AbortError") return; toast.error("Failed to load menus.", { description: e?.message ? String(e.message) : "Please try again." }); setMenuTree([]); setSelectedIds(new Set()); } finally { setLoadingMenus(false); } }; run(); return () => abortRef.current?.abort(); }, [open, roleId]); const menuTotal = useMemo(() => { const walk = (nodes: RbacMenuTreeNode[]): number => nodes.reduce((acc, n) => acc + 1 + (n.children ? walk(n.children) : 0), 0); return walk(menuTree); }, [menuTree]); const filterTree = useMemo(() => { const kw = debouncedMenuKeyword.trim().toLowerCase(); if (!kw) return menuTree; const match = (n: RbacMenuTreeNode) => { const name = (n.menuName ?? "").toLowerCase(); const url = (n.routeUrl ?? "").toLowerCase(); return name.includes(kw) || url.includes(kw); }; const recur = (nodes: RbacMenuTreeNode[]): RbacMenuTreeNode[] => { const out: RbacMenuTreeNode[] = []; for (const n of nodes) { const children = n.children ? recur(n.children) : []; if (match(n) || children.length) out.push({ ...n, children: children.length ? children : undefined }); } return out; }; return recur(menuTree); }, [menuTree, debouncedMenuKeyword]); useEffect(() => { const kw = debouncedMenuKeyword.trim(); if (!kw) return; const next = new Set(); const walk = (nodes: RbacMenuTreeNode[]) => { for (const n of nodes) { if (n.children?.length) next.add(n.id); if (n.children?.length) walk(n.children); } }; walk(filterTree); setMenuExpandedIds(next); }, [debouncedMenuKeyword, filterTree]); const getNodeAllIds = (node: RbacMenuTreeNode): string[] => { const ids: string[] = []; const walk = (n: RbacMenuTreeNode) => { if (n.id) ids.push(n.id); if (n.children?.length) n.children.forEach(walk); }; walk(node); return ids; }; const isCheckedState = (node: RbacMenuTreeNode): { checked: boolean; indeterminate: boolean } => { const ids = getNodeAllIds(node); if (!ids.length) return { checked: false, indeterminate: false }; let hit = 0; for (const id of ids) if (selectedIds.has(id)) hit += 1; if (hit === 0) return { checked: false, indeterminate: false }; if (hit === ids.length) return { checked: true, indeterminate: false }; return { checked: false, indeterminate: true }; }; const toggleNode = (node: RbacMenuTreeNode, checked: boolean) => { setSelectedIds((prev) => { const next = new Set(prev); const ids = getNodeAllIds(node); if (checked) ids.forEach((id) => next.add(id)); else ids.forEach((id) => next.delete(id)); return next; }); }; const toggleExpanded = (id: string) => { setMenuExpandedIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const highlight = (text: string | null | undefined) => { const kw = debouncedMenuKeyword.trim(); const t = text ?? ""; if (!kw) return t || "N/A"; const idx = t.toLowerCase().indexOf(kw.toLowerCase()); if (idx < 0) return t || "N/A"; const a = t.slice(0, idx); const b = t.slice(idx, idx + kw.length); const c = t.slice(idx + kw.length); return ( {a} {b} {c} ); }; const TreeNodeRow = ({ node, depth }: { node: RbacMenuTreeNode; depth: number }) => { const hasChildren = !!node.children?.length; const expanded = menuExpandedIds.has(node.id); const { checked, indeterminate } = isCheckedState(node); return (
toggleNode(node, !!v)} />
{hasChildren && expanded && (
{node.children!.map((c) => ( ))}
)}
); }; const submit = async () => { console.log("submit", role); if (!roleId) return; setSubmitting(true); try { await setRoleMenus({ roleId, menuIds: Array.from(selectedIds), }); toast.success("Role menu permissions saved.", { description: "Menu permissions have been updated successfully.", }); onOpenChange(false); } catch (e: any) { toast.error("Failed to save menu permissions.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; const clearAll = async () => { if (!roleId || selectedIds.size === 0) return; setSubmitting(true); try { await deleteRoleMenus({ roleId, menuIds: Array.from(selectedIds), }); setSelectedIds(new Set()); toast.success("Role menu permissions cleared.", { description: "Selected permissions have been removed.", }); } catch (e: any) { toast.error("Failed to delete menu permissions.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; return ( Menu Permissions {roleName ? `Set menu permissions for role: ${roleName}` : "Set menu permissions for this role."}
{loadingMenus ? "Loading menus..." : `Total ${menuTotal} menus`}
setMenuKeyword(e.target.value)} placeholder="Search menus" className="h-8 w-44 bg-white" />
{filterTree.map((n) => ( ))} {!loadingMenus && filterTree.length === 0 && (
No menus.
)}
); } function DeleteRoleDialog({ open, role, onOpenChange, onDeleted, }: { open: boolean; role: RoleDto | null; onOpenChange: (open: boolean) => void; onDeleted: () => void; }) { const [submitting, setSubmitting] = useState(false); const name = useMemo(() => { const n = (role?.roleName ?? "").trim(); return n || role?.roleCode || role?.id || "this role"; }, [role]); const submit = async () => { console.log("submit", role); if (!role?.id) return; setSubmitting(true); try { await deleteRbacRole(role.id); toast.success("Role deleted.", { description: "The role has been removed successfully.", }); onOpenChange(false); onDeleted(); } catch (e: any) { toast.error("Failed to delete role.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; return ( Delete Role This action cannot be undone.
Are you sure you want to delete {name}?
); } function CreatePartnerDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { return ( Create New Partner
); } function CreateGroupDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { return ( Create New Group
); } function MemberDialog({ open, member, onOpenChange, onSaved, }: { open: boolean; member: TeamMemberDto | null; onOpenChange: (open: boolean) => void; onSaved: () => void; }) { const isEdit = !!member?.id; const [submitting, setSubmitting] = useState(false); const [fullName, setFullName] = useState(""); const [userName, setUserName] = useState(""); const [password, setPassword] = useState(""); const [email, setEmail] = useState(""); const [phone, setPhone] = useState(""); const [roleId, setRoleId] = useState(""); const [state, setState] = useState(true); const [selectedLocationIds, setSelectedLocationIds] = useState>(new Set()); const [roleOptions, setRoleOptions] = useState([]); const [loadingRoles, setLoadingRoles] = useState(false); const [locationOptions, setLocationOptions] = useState([]); const [loadingLocations, setLoadingLocations] = useState(false); const [locationKeyword, setLocationKeyword] = useState(""); const abortRef = useRef(null); const resetForm = () => { setFullName(""); setUserName(""); setPassword(""); setEmail(""); setPhone(""); setRoleId(""); setState(true); setSelectedLocationIds(new Set()); }; const loadAllRolesForSelect = async (signal: AbortSignal) => { const out: RoleDto[] = []; let page = 1; const size = 100; for (;;) { const res = await getRoles({ skipCount: page, maxResultCount: size }, signal); out.push(...(res.items ?? [])); if (!res.items || res.items.length < size) break; page += 1; if (page > 200) break; // safety bound } // 去重(防止后端实现差异) const m = new Map(); for (const r of out) if (r.id && !m.has(r.id)) m.set(r.id, r); return Array.from(m.values()); }; const loadAllLocationsForSelect = async (signal: AbortSignal) => { const out: LocationDto[] = []; let page = 1; const size = 200; for (;;) { const res = await getLocations({ skipCount: page, maxResultCount: size }, signal); out.push(...(res.items ?? [])); if (!res.items || res.items.length < size) break; page += 1; if (page > 200) break; } const m = new Map(); for (const l of out) if (l.id && !m.has(l.id)) m.set(l.id, l); return Array.from(m.values()); }; useEffect(() => { if (!open) return; abortRef.current?.abort(); const ac = new AbortController(); abortRef.current = ac; setSubmitting(false); resetForm(); setLoadingRoles(true); setLoadingLocations(true); const run = async () => { try { const [rolesRes, locationsRes] = await Promise.all([ loadAllRolesForSelect(ac.signal), loadAllLocationsForSelect(ac.signal), ]); setRoleOptions(rolesRes); setLocationOptions(locationsRes); if (member?.id) { const detail = await getTeamMemberById(member.id, ac.signal); setFullName(detail.fullName ?? ""); setUserName(detail.userName ?? ""); setEmail(detail.email ?? ""); setPhone(detail.phone != null ? String(detail.phone) : ""); // 有些后端只返回 roleName 而不返回 roleId;这里做兜底匹配,避免编辑后 Save 变成 disabled let nextRoleId = (detail.roleId ?? "").toString().trim(); if (!nextRoleId && detail.roleName) { const roleName = String(detail.roleName).trim().toLowerCase(); const matched = rolesRes.find((r) => { const rn = String(r.roleName ?? "").trim().toLowerCase(); const rc = String(r.roleCode ?? "").trim().toLowerCase(); const rid = String(r.id ?? "").trim().toLowerCase(); return rn === roleName || rc === roleName || rid === roleName; }); if (matched?.id) nextRoleId = matched.id; } setRoleId(nextRoleId); setState(!!detail.state); const ids = detail.locationIds && detail.locationIds.length ? detail.locationIds : []; if (ids.length) { setSelectedLocationIds(new Set(ids)); } else if (detail.locations?.length) { // 如果后端只返回 locations(名字),则尽力映射回 id const labels = new Set(detail.locations); const inferred = new Set(); for (const l of locationsRes) { const label1 = `${(l.locationCode ?? "").trim()} - ${(l.locationName ?? "").trim()}`.trim(); const label2 = (l.locationName ?? "").trim(); const label3 = (l.locationCode ?? "").trim(); if (labels.has(label1) || labels.has(label2) || labels.has(label3)) inferred.add(l.id); } setSelectedLocationIds(inferred); } } } catch (e: any) { if (e?.name !== "AbortError") { toast.error("Failed to load user form.", { description: e?.message ? String(e.message) : "Please try again.", }); } } finally { setLoadingRoles(false); setLoadingLocations(false); } }; run(); return () => ac.abort(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, member?.id]); // Same as create: Save enabled when required fields are filled; empty values show error on submit. const canSubmit = useMemo(() => { if (!fullName.trim()) return false; if (!userName.trim()) return false; if (!roleId.trim()) return false; if (selectedLocationIds.size === 0) return false; if (!isEdit && !password.trim()) return false; return true; }, [fullName, userName, roleId, selectedLocationIds, isEdit, password]); const toggleLocation = (id: string, checked: boolean) => { setSelectedLocationIds((prev) => { const next = new Set(prev); if (checked) next.add(id); else next.delete(id); return next; }); }; const submit = async (e?: React.MouseEvent) => { e?.preventDefault(); e?.stopPropagation(); console.log("[MemberDialog] submit called", { isEdit, memberId: member?.id, canSubmit, roleId, fullName, userName, selectedLocationIds: selectedLocationIds.size }); // 先校验必填项 if (!canSubmit) { const missing: string[] = []; if (!fullName.trim()) missing.push("Full Name"); if (!userName.trim()) missing.push("User Name"); if (!roleId.trim()) missing.push("Role"); if (selectedLocationIds.size === 0) missing.push("Locations"); if (!isEdit && !password.trim()) missing.push("Password"); toast.error("Missing required fields.", { description: `Please fill: ${missing.join("、")}.`, }); return; } if (!isEdit && !member?.id) { // 新增模式 setSubmitting(true); try { const locationIds = Array.from(selectedLocationIds); console.log("[MemberDialog] Creating user", { fullName, userName, roleId, locationIds }); await createTeamMember({ fullName: fullName.trim(), userName: userName.trim(), password: password.trim(), email: email.trim() ? email.trim() : null, phone: phone != null && String(phone).trim() ? String(phone).trim() : null, roleId: roleId.trim(), locationIds, state, }); toast.success("User created.", { description: "A new user has been created successfully." }); onOpenChange(false); onSaved(); } catch (e: any) { console.error("[MemberDialog] Create error", e); toast.error("Failed to create user.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } } else if (isEdit && member?.id) { // 编辑模式 setSubmitting(true); try { const locationIds = Array.from(selectedLocationIds); console.log("[MemberDialog] Updating user", { id: member.id, fullName, userName, roleId, locationIds }); await updateTeamMember(member.id, { fullName: fullName.trim(), userName: userName.trim(), password: password.trim() ? password.trim() : null, email: email.trim() ? email.trim() : null, phone: phone != null && String(phone).trim() ? String(phone).trim() : null, roleId: roleId.trim(), locationIds, state, }); toast.success("User updated.", { description: "Changes have been saved successfully." }); onOpenChange(false); onSaved(); } catch (e: any) { console.error("[MemberDialog] Update error", e); toast.error("Failed to update user.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } } else { console.error("[MemberDialog] Invalid state", { isEdit, memberId: member?.id }); toast.error("Invalid form state.", { description: "Please refresh and try again.", }); } }; const locationLabel = (l: LocationDto) => { const code = (l.locationCode ?? "").trim(); const name = (l.locationName ?? "").trim(); return code && name ? `${code} - ${name}` : name || code || l.id; }; const filteredLocations = useMemo(() => { const kw = locationKeyword.trim().toLowerCase(); if (!kw) return locationOptions; return locationOptions.filter((l) => locationLabel(l).toLowerCase().includes(kw)); }, [locationOptions, locationKeyword]); return ( {isEdit ? "Edit User" : "New User"} Role is single-select; Locations is multi-select.
setFullName(e.target.value)} placeholder="John Doe" />
setUserName(e.target.value)} placeholder="username" />
{!isEdit && (
setPassword(e.target.value)} placeholder="Enter password" autoComplete="new-password" className="w-full" />
)} {isEdit && (
setPassword(e.target.value)} placeholder="Enter new password (optional)" autoComplete="new-password" className="w-full" />
)}
setEmail(e.target.value)} placeholder="john@example.com" />
setPhone(e.target.value)} placeholder="+1 (555) 000-0000" />
setLocationKeyword(e.target.value)} placeholder="Search locations" className="h-9" />
{selectedLocationIds.size} selected
{loadingLocations ? (
Loading...
) : ( filteredLocations.map((l) => (
toggleLocation(l.id, !!v)} />
)) )}

* Users must be assigned to at least one location.

); } function DeleteMemberDialog({ open, member, onOpenChange, onDeleted, }: { open: boolean; member: TeamMemberDto | null; onOpenChange: (open: boolean) => void; onDeleted: () => void; }) { const [submitting, setSubmitting] = useState(false); const name = useMemo(() => { const n = (member?.fullName ?? "").trim(); const code = (member?.userName ?? "").trim(); return n || code || "this user"; }, [member?.fullName, member?.userName]); const submit = async () => { console.log("submit", member); if (!member?.id) return; setSubmitting(true); try { await deleteTeamMember(member.id); toast.success("User deleted.", { description: "The user has been removed successfully." }); onOpenChange(false); onDeleted(); } catch (e: any) { toast.error("Failed to delete user.", { description: e?.message ? String(e.message) : "Please try again.", }); } finally { setSubmitting(false); } }; return ( Delete User This action cannot be undone.
Are you sure you want to delete {name}?
); }