Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/dashboard/user-management/UserManagementPage.tsx
Michael Abebe 88a280eea2 WIP
2026-06-02 13:50:54 +03:00

2284 lines
81 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { isAxiosError } from "axios";
import {
Badge,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@edr/ui-common";
import {
Building2,
ChevronRight,
FolderTree,
MoreHorizontal,
Network,
RefreshCw,
Search,
ShieldCheck,
UserCheck,
UserMinus,
Users,
} from "lucide-react";
import { api } from "@/auth/http";
import { useAuth } from "@/auth/useAuth";
interface LocaleText {
en?: string;
am?: string;
}
interface OrganizationRecord {
id: string;
key: string;
name: LocaleText;
}
interface UnitRecord {
id: string;
key: string;
name: LocaleText;
parentUnitId?: string | null;
}
interface PositionRecord {
id: string;
key: string;
name: LocaleText;
parentPositionId?: string | null;
positionTypeId?: string | null;
rank?: number;
}
interface PositionTypeRecord {
id: string;
key: string;
name: LocaleText;
}
interface EmployeeUserRecord {
id: string;
name?: LocaleText;
email?: string;
phoneNumber?: string;
username?: string;
}
interface EmployeePositionSummary {
id: string;
position?: {
id: string;
name?: LocaleText;
};
}
interface EmployeeRecord {
id: string;
name?: LocaleText;
user?: EmployeeUserRecord;
status?: string;
employeePositions?: EmployeePositionSummary[];
}
interface RoleRecord {
id: string;
key: string;
name: LocaleText;
}
interface PositionTreeNode extends PositionRecord {
children: PositionTreeNode[];
}
interface OrganizationConfigurationRecord {
id: string;
organizationId: string;
maximumNumberOfUnits?: number | null;
canCreateBranchByItself?: boolean;
canStartReceivingRecord?: boolean;
}
interface PositionFormState {
id?: string;
nameEn: string;
nameAm: string;
key: string;
parentPositionId: string;
positionTypeId: string;
}
interface UnitFormState {
nameEn: string;
nameAm: string;
key: string;
}
interface EmployeeInviteFormState {
nameEn: string;
nameAm: string;
email: string;
username: string;
phoneNumber: string;
}
interface ListResponse<T> {
count?: number;
items?: T[];
data?: T[];
}
const RESERVED_ROLE_KEYS = new Set(["super_admin", "organization_admin", "unit_admin"]);
const emptyInviteForm: EmployeeInviteFormState = {
nameEn: "",
nameAm: "",
email: "",
username: "",
phoneNumber: "",
};
const emptyUnitForm: UnitFormState = {
nameEn: "",
nameAm: "",
key: "",
};
const emptyPositionForm: PositionFormState = {
nameEn: "",
nameAm: "",
key: "",
parentPositionId: "",
positionTypeId: "",
};
const inputClassName =
"w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-950";
const buttonClassName =
"inline-flex items-center justify-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60";
const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") => {
if (!value) {
return fallback;
}
return value.en ?? value.am ?? fallback;
};
const getItems = <T,>(payload: ListResponse<T> | T[] | undefined | null) => {
if (!payload) {
return [] as T[];
}
if (Array.isArray(payload)) {
return payload;
}
return payload.items ?? payload.data ?? [];
};
const mergeEmployeesByUser = (employees: EmployeeRecord[]) => {
const employeesByUserId = new Map<string, EmployeeRecord>();
for (const employee of employees) {
const userId = employee.user?.id;
if (!userId) {
employeesByUserId.set(employee.id, employee);
continue;
}
const existing = employeesByUserId.get(userId);
if (!existing) {
employeesByUserId.set(userId, employee);
continue;
}
const existingPositions = existing.employeePositions ?? [];
const nextPositions = employee.employeePositions ?? [];
const mergedPositions = Array.from(
new Map(
[...existingPositions, ...nextPositions].map((position) => [position.id, position]),
).values(),
);
employeesByUserId.set(userId, {
...existing,
...employee,
id: existing.id,
name: existing.name ?? employee.name,
status: existing.status ?? employee.status,
user: existing.user ?? employee.user,
employeePositions: mergedPositions,
});
}
return [...employeesByUserId.values()];
};
const toInternalKey = (value: string) =>
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/_+/g, "_");
const buildTree = (positions: PositionRecord[]) => {
const nodes = new Map<string, PositionTreeNode>();
positions.forEach((position) => {
nodes.set(position.id, { ...position, children: [] });
});
const roots: PositionTreeNode[] = [];
nodes.forEach((node) => {
if (node.parentPositionId) {
const parent = nodes.get(node.parentPositionId);
if (parent) {
parent.children.push(node);
return;
}
}
roots.push(node);
});
const sortNodes = (items: PositionTreeNode[]) => {
items.sort((left, right) => {
const rankDiff = (left.rank ?? 0) - (right.rank ?? 0);
if (rankDiff !== 0) {
return rankDiff;
}
return getLocaleLabel(left.name, left.key).localeCompare(
getLocaleLabel(right.name, right.key),
);
});
items.forEach((item) => sortNodes(item.children));
};
sortNodes(roots);
return roots;
};
const getErrorMessage = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (typeof message === "string") {
return message;
}
if (Array.isArray(message) && typeof message[0] === "string") {
return message[0];
}
}
return error instanceof Error ? error.message : fallback;
};
const Field = ({ label, children }: { label: string; children: ReactNode }) => (
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium text-foreground">{label}</span>
{children}
</label>
);
const ManagementDialog = ({
open,
title,
description,
onOpenChange,
children,
}: {
open: boolean;
title: string;
description?: string;
onOpenChange: (open: boolean) => void;
children: ReactNode;
}) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
{children}
</DialogContent>
</Dialog>
);
const TreeNode = ({
node,
depth = 0,
selectedPositionId,
onSelect,
expandedDepartmentIds,
onToggleExpand,
onInviteUser,
onAssignUser,
onAddSubDepartment,
onEdit,
onDelete,
disabled,
}: {
node: PositionTreeNode;
depth?: number;
selectedPositionId: string | null;
onSelect: (node: PositionRecord) => void;
expandedDepartmentIds: Set<string>;
onToggleExpand: (nodeId: string) => void;
onInviteUser: (node: PositionRecord) => void;
onAssignUser: (node: PositionRecord) => void;
onAddSubDepartment: (node: PositionRecord) => void;
onEdit: (node: PositionRecord) => void;
onDelete: (node: PositionRecord) => void;
disabled?: boolean;
}) => {
const hasChildren = node.children.length > 0;
const isSelected = selectedPositionId === node.id;
const isExpanded = expandedDepartmentIds.has(node.id);
return (
<li className="group">
<div
className={`flex w-full items-center gap-2 rounded-xl border px-3 py-2 text-left text-sm shadow-sm transition ${
isSelected
? "border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-100"
: "border-border bg-card text-card-foreground hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:bg-slate-900"
}`}
style={{ marginLeft: depth * 14 }}
>
{hasChildren ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onToggleExpand(node.id);
}}
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground transition hover:bg-accent hover:text-accent-foreground"
disabled={disabled}
aria-label={isExpanded ? "Collapse department" : "Expand department"}
>
<ChevronRight
className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-90" : "rotate-0"}`}
/>
</button>
) : (
<span className="h-7 w-7 shrink-0" aria-hidden="true" />
)}
<button
type="button"
onClick={() => onSelect(node)}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
disabled={disabled}
>
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-emerald-50 text-emerald-600 dark:bg-emerald-950/40 dark:text-emerald-300">
{hasChildren ? <FolderTree className="h-4 w-4" /> : <Building2 className="h-4 w-4" />}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{getLocaleLabel(node.name, node.key)}</div>
<div className="truncate text-xs text-muted-foreground">{node.key}</div>
</div>
{hasChildren ? <ChevronRight className="h-4 w-4 text-muted-foreground" /> : null}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
onClick={(event) => event.stopPropagation()}
className="ml-auto inline-flex h-9 w-9 items-center justify-center rounded-xl border border-border bg-card text-card-foreground opacity-0 transition group-hover:opacity-100 group-focus-within:opacity-100 hover:bg-accent hover:text-accent-foreground"
aria-label="Position actions"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onInviteUser(node);
}}
>
Add User
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onAssignUser(node);
}}
>
Assign User
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onAddSubDepartment(node);
}}
>
Add Sub-department
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onEdit(node);
}}
>
Edit Department
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={(event) => {
event.preventDefault();
onDelete(node);
}}
>
Delete Department
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{hasChildren && isExpanded ? (
<ul className="mt-2 space-y-2">
{node.children.map((child) => (
<TreeNode
key={child.id}
node={child}
depth={depth + 1}
selectedPositionId={selectedPositionId}
onSelect={onSelect}
expandedDepartmentIds={expandedDepartmentIds}
onToggleExpand={onToggleExpand}
onInviteUser={onInviteUser}
onAssignUser={onAssignUser}
onAddSubDepartment={onAddSubDepartment}
onEdit={onEdit}
onDelete={onDelete}
disabled={disabled}
/>
))}
</ul>
) : null}
</li>
);
};
const UserManagementPage = () => {
const { user } = useAuth();
const [organizations, setOrganizations] = useState<OrganizationRecord[]>([]);
const [units, setUnits] = useState<UnitRecord[]>([]);
const [positions, setPositions] = useState<PositionRecord[]>([]);
const [positionTypes, setPositionTypes] = useState<PositionTypeRecord[]>([]);
const [orgEmployees, setOrgEmployees] = useState<EmployeeRecord[]>([]);
const [positionMembers, setPositionMembers] = useState<EmployeeRecord[]>([]);
const [selectedOrgConfiguration, setSelectedOrgConfiguration] =
useState<OrganizationConfigurationRecord | null>(null);
const [expandedDepartmentIds, setExpandedDepartmentIds] = useState<Set<string>>(new Set());
const [orgAdminUserIds, setOrgAdminUserIds] = useState<Set<string>>(new Set());
const [unitAdminUserIds, setUnitAdminUserIds] = useState<Set<string>>(new Set());
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const [selectedUnitId, setSelectedUnitId] = useState<string | null>(null);
const [selectedPositionId, setSelectedPositionId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [membersLoading, setMembersLoading] = useState(false);
const [orgEmployeesLoading, setOrgEmployeesLoading] = useState(false);
const [positionTypesLoading, setPositionTypesLoading] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [positionTypesError, setPositionTypesError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [unitSearch, setUnitSearch] = useState("");
const [positionSearch, setPositionSearch] = useState("");
const [employeeSearch, setEmployeeSearch] = useState("");
const [assignSearch, setAssignSearch] = useState("");
const [unitForm, setUnitForm] = useState<UnitFormState>(emptyUnitForm);
const [positionForm, setPositionForm] = useState<PositionFormState>(emptyPositionForm);
const [inviteForm, setInviteForm] = useState<EmployeeInviteFormState>(emptyInviteForm);
const [roleIds, setRoleIds] = useState<string[]>([]);
const [availableRoles, setAvailableRoles] = useState<RoleRecord[]>([]);
const [rolesLoading, setRolesLoading] = useState(false);
const [isCreateUnitOpen, setIsCreateUnitOpen] = useState(false);
const [isEditUnitOpen, setIsEditUnitOpen] = useState(false);
const [isCreatePositionOpen, setIsCreatePositionOpen] = useState(false);
const [isEditPositionOpen, setIsEditPositionOpen] = useState(false);
const [isInviteEmployeeOpen, setIsInviteEmployeeOpen] = useState(false);
const [isAssignEmployeeOpen, setIsAssignEmployeeOpen] = useState(false);
const [isManageRolesOpen, setIsManageRolesOpen] = useState(false);
const [selectedRoleUser, setSelectedRoleUser] = useState<EmployeeRecord | null>(null);
const [createPositionMode, setCreatePositionMode] = useState<"create-root" | "create-child">(
"create-root",
);
const isSuperAdmin = Boolean(user?.roles?.some((role) => role.key === "super_admin"));
const allowedOrgIds = useMemo(
() => new Set((user?.employee ?? []).map((employee) => employee.organizationId).filter(Boolean)),
[user?.employee],
);
const visibleOrganizations = useMemo(() => {
if (isSuperAdmin) {
return organizations;
}
return organizations.filter((organization) => allowedOrgIds.has(organization.id));
}, [allowedOrgIds, isSuperAdmin, organizations]);
const selectedOrganization = useMemo(
() => visibleOrganizations.find((item) => item.id === selectedOrgId) ?? null,
[selectedOrgId, visibleOrganizations],
);
const selectedUnit = useMemo(
() => units.find((item) => item.id === selectedUnitId) ?? null,
[selectedUnitId, units],
);
const selectedPosition = useMemo(
() => positions.find((item) => item.id === selectedPositionId) ?? null,
[positions, selectedPositionId],
);
const positionTree = useMemo(() => buildTree(positions), [positions]);
const parentOptions = useMemo(
() => positions.filter((item) => item.id !== selectedPositionId),
[positions, selectedPositionId],
);
const unitsAllowedLabel =
selectedOrgConfiguration?.maximumNumberOfUnits == null
? "∞"
: `${selectedOrgConfiguration.maximumNumberOfUnits}`;
const filteredUnits = useMemo(() => {
const query = unitSearch.trim().toLowerCase();
if (!query) {
return units;
}
return units.filter((item) => {
const label = getLocaleLabel(item.name, item.key).toLowerCase();
return label.includes(query) || item.key.toLowerCase().includes(query);
});
}, [unitSearch, units]);
const filteredPositionTree = useMemo(() => {
const query = positionSearch.trim().toLowerCase();
if (!query) {
return positionTree;
}
const filterNode = (node: PositionTreeNode): PositionTreeNode | null => {
const label = `${getLocaleLabel(node.name, node.key)} ${node.key}`.toLowerCase();
const matchingChildren = node.children
.map((child) => filterNode(child))
.filter(Boolean) as PositionTreeNode[];
if (label.includes(query) || matchingChildren.length) {
return { ...node, children: matchingChildren };
}
return null;
};
return positionTree.map((node) => filterNode(node)).filter(Boolean) as PositionTreeNode[];
}, [positionSearch, positionTree]);
const filteredPositionMembers = useMemo(() => {
const query = employeeSearch.trim().toLowerCase();
if (!query) {
return positionMembers;
}
return positionMembers.filter((employee) => {
const name = getLocaleLabel(employee.name ?? employee.user?.name, employee.user?.email ?? employee.id).toLowerCase();
const email = employee.user?.email?.toLowerCase() ?? "";
const username = employee.user?.username?.toLowerCase() ?? "";
return name.includes(query) || email.includes(query) || username.includes(query);
});
}, [employeeSearch, positionMembers]);
const assignableEmployees = useMemo(() => {
const query = assignSearch.trim().toLowerCase();
return orgEmployees.filter((employee) => {
const label = getLocaleLabel(employee.name ?? employee.user?.name, employee.user?.email ?? employee.id).toLowerCase();
const email = employee.user?.email?.toLowerCase() ?? "";
if (!query) {
return true;
}
return label.includes(query) || email.includes(query);
});
}, [assignSearch, orgEmployees]);
const resetMessages = () => {
setActionError(null);
setActionSuccess(null);
};
const loadOrganizations = useCallback(async () => {
setLoading(true);
setLoadError(null);
try {
const response = await api.get<ListResponse<OrganizationRecord>>("/organizations");
const nextOrganizations = getItems(response.data);
setOrganizations(nextOrganizations);
} catch (error) {
setLoadError(getErrorMessage(error, "Failed to load organizations."));
} finally {
setLoading(false);
}
}, []);
const loadOrgEmployees = useCallback(async (organizationId: string) => {
setOrgEmployeesLoading(true);
try {
const response = await api.get<ListResponse<EmployeeRecord>>(
`/backoffice/organizations/${organizationId}/employees`,
);
setOrgEmployees(mergeEmployeesByUser(getItems(response.data)));
} catch {
setOrgEmployees([]);
} finally {
setOrgEmployeesLoading(false);
}
}, []);
const loadOrgAdmins = useCallback(async (organizationId: string) => {
try {
const response = await api.get<ListResponse<EmployeeRecord>>(
`/organizations/org-admins/${organizationId}`,
);
setOrgAdminUserIds(
new Set(getItems(response.data).map((employee) => employee.user?.id).filter(Boolean) as string[]),
);
} catch {
setOrgAdminUserIds(new Set());
}
}, []);
const loadUnitAdmins = useCallback(async (unitId: string | null) => {
if (!unitId) {
setUnitAdminUserIds(new Set());
return;
}
try {
const response = await api.get<ListResponse<EmployeeRecord>>(`/units/unit-admins/${unitId}`);
setUnitAdminUserIds(
new Set(getItems(response.data).map((employee) => employee.user?.id).filter(Boolean) as string[]),
);
} catch {
setUnitAdminUserIds(new Set());
}
}, []);
const loadUnits = useCallback(async (organizationId: string) => {
try {
const response = await api.get<ListResponse<UnitRecord>>(`/units/list/${organizationId}`);
setUnits(getItems(response.data));
} catch (error) {
setLoadError(getErrorMessage(error, "Failed to load units."));
setUnits([]);
}
}, []);
const loadOrganizationConfiguration = useCallback(async (organizationId: string) => {
try {
const response = await api.get<ListResponse<OrganizationConfigurationRecord>>(
`/organization-configurations/list/${organizationId}`,
);
setSelectedOrgConfiguration(getItems(response.data)[0] ?? null);
} catch {
setSelectedOrgConfiguration(null);
}
}, []);
const loadPositions = useCallback(async (unitId: string) => {
try {
const response = await api.get<ListResponse<PositionRecord>>(`/positions/list/${unitId}`);
setPositions(getItems(response.data));
} catch (error) {
setLoadError(getErrorMessage(error, "Failed to load positions."));
setPositions([]);
}
}, []);
const loadPositionTypes = useCallback(async (unitId: string) => {
setPositionTypesLoading(true);
setPositionTypesError(null);
try {
const response = await api.get<ListResponse<PositionTypeRecord>>(
`/position-types/list-with-commons/${unitId}?take=1000&skip=0&orderBy=createdAt:Desc`,
);
setPositionTypes(getItems(response.data));
} catch (error) {
const message = getErrorMessage(error, "Failed to load position types.");
setPositionTypes([]);
setPositionTypesError(message);
throw error;
} finally {
setPositionTypesLoading(false);
}
}, []);
const loadPositionMembers = useCallback(async (positionId: string | null) => {
if (!positionId) {
setPositionMembers([]);
return;
}
setMembersLoading(true);
try {
const response = await api.get<ListResponse<EmployeeRecord>>(
`/positions/current/${positionId}/employees`,
);
setPositionMembers(getItems(response.data));
} catch {
setPositionMembers([]);
} finally {
setMembersLoading(false);
}
}, []);
const refreshOrganizations = useCallback(async () => {
await loadOrganizations();
}, [loadOrganizations]);
const refreshSelectedOrg = useCallback(async () => {
if (!selectedOrgId) {
setUnits([]);
setPositions([]);
setPositionMembers([]);
setOrgEmployees([]);
setSelectedOrgConfiguration(null);
return;
}
await Promise.all([
loadUnits(selectedOrgId),
loadOrgEmployees(selectedOrgId),
loadOrgAdmins(selectedOrgId),
loadOrganizationConfiguration(selectedOrgId),
]);
}, [loadOrgAdmins, loadOrgEmployees, loadOrganizationConfiguration, loadUnits, selectedOrgId]);
const refreshSelectedUnit = useCallback(async () => {
if (!selectedUnitId) {
setPositions([]);
setPositionMembers([]);
setUnitAdminUserIds(new Set());
return;
}
await Promise.all([loadPositions(selectedUnitId), loadUnitAdmins(selectedUnitId)]);
}, [loadPositions, loadUnitAdmins, selectedUnitId]);
useEffect(() => {
void loadOrganizations();
}, [loadOrganizations]);
useEffect(() => {
if (!visibleOrganizations.length) {
setSelectedOrgId(null);
setSelectedOrgConfiguration(null);
setUnits([]);
setPositions([]);
setPositionMembers([]);
setOrgEmployees([]);
return;
}
if (selectedOrgId && visibleOrganizations.some((organization) => organization.id === selectedOrgId)) {
return;
}
setSelectedOrgId(visibleOrganizations[0]?.id ?? null);
setSelectedOrgConfiguration(null);
setUnits([]);
setPositions([]);
setPositionMembers([]);
setOrgEmployees([]);
}, [selectedOrgId, visibleOrganizations]);
useEffect(() => {
if (!selectedOrgId) {
return;
}
void refreshSelectedOrg();
}, [refreshSelectedOrg, selectedOrgId]);
useEffect(() => {
if (!selectedOrgId) {
setUnits([]);
setPositions([]);
setPositionTypes([]);
setPositionTypesError(null);
setSelectedUnitId(null);
setSelectedPositionId(null);
setExpandedDepartmentIds(new Set());
setPositionMembers([]);
setOrgEmployees([]);
setOrgAdminUserIds(new Set());
setSelectedOrgConfiguration(null);
return;
}
}, [selectedOrgId]);
useEffect(() => {
if (!selectedOrgId || !selectedUnitId) {
return;
}
if (!units.some((unit) => unit.id === selectedUnitId)) {
setSelectedUnitId(null);
}
}, [selectedOrgId, selectedUnitId, units]);
useEffect(() => {
if (!selectedUnitId) {
setPositions([]);
setPositionTypes([]);
setPositionTypesError(null);
setSelectedPositionId(null);
setExpandedDepartmentIds(new Set());
setPositionMembers([]);
setUnitAdminUserIds(new Set());
return;
}
}, [selectedUnitId]);
useEffect(() => {
if (!selectedUnitId || !selectedPositionId) {
return;
}
if (!positions.some((position) => position.id === selectedPositionId)) {
setSelectedPositionId(null);
}
}, [positions, selectedPositionId, selectedUnitId]);
useEffect(() => {
if (!selectedPositionId) {
setPositionMembers([]);
return;
}
}, [selectedPositionId]);
useEffect(() => {
void loadPositionMembers(selectedPositionId);
}, [loadPositionMembers, selectedPositionId]);
const handleRefresh = async () => {
resetMessages();
await Promise.all([
refreshOrganizations(),
selectedOrgId ? refreshSelectedOrg() : Promise.resolve(),
selectedUnitId ? refreshSelectedUnit() : Promise.resolve(),
loadPositionMembers(selectedPositionId),
]);
};
const handleSelectOrganization = async (organizationId: string) => {
setSelectedOrgId(organizationId);
setSelectedUnitId(null);
setSelectedPositionId(null);
setExpandedDepartmentIds(new Set());
setUnits([]);
setPositions([]);
setPositionMembers([]);
setOrgEmployees([]);
setOrgAdminUserIds(new Set());
setUnitAdminUserIds(new Set());
setSelectedOrgConfiguration(null);
resetMessages();
try {
await Promise.all([
loadUnits(organizationId),
loadOrgEmployees(organizationId),
loadOrgAdmins(organizationId),
loadOrganizationConfiguration(organizationId),
]);
} catch {
// Individual loaders already handle their own error state.
}
};
const handleSelectUnit = async (unitId: string) => {
setSelectedUnitId(unitId);
setSelectedPositionId(null);
setExpandedDepartmentIds(new Set());
setPositions([]);
setPositionMembers([]);
setUnitAdminUserIds(new Set());
resetMessages();
try {
await Promise.all([loadPositions(unitId), loadUnitAdmins(unitId)]);
} catch {
// Individual loaders already handle their own error state.
}
};
const handleToggleDepartment = (departmentId: string) => {
setExpandedDepartmentIds((current) => {
const next = new Set(current);
if (next.has(departmentId)) {
next.delete(departmentId);
} else {
next.add(departmentId);
}
return next;
});
};
const openCreateSubDepartmentDialog = async (parent: PositionRecord) => {
if (!selectedUnit) {
setActionError("Select a unit before creating a department.");
return;
}
resetMessages();
try {
await loadPositionTypes(selectedUnit.id);
setSelectedPositionId(parent.id);
setExpandedDepartmentIds((current) => new Set(current).add(parent.id));
setCreatePositionMode("create-child");
setPositionForm({
...emptyPositionForm,
parentPositionId: parent.id,
});
setIsCreatePositionOpen(true);
} catch (error) {
setActionError(getErrorMessage(error, "Failed to load position types."));
}
};
const openInviteForDepartment = (position: PositionRecord) => {
setSelectedPositionId(position.id);
resetMessages();
setIsInviteEmployeeOpen(true);
};
const openAssignForDepartment = (position: PositionRecord) => {
setSelectedPositionId(position.id);
resetMessages();
setIsAssignEmployeeOpen(true);
};
const openCreateUnitDialog = () => {
setUnitForm(emptyUnitForm);
resetMessages();
setIsCreateUnitOpen(true);
};
const openEditUnitDialog = (unit: UnitRecord) => {
setUnitForm({
nameEn: unit.name.en ?? "",
nameAm: unit.name.am ?? "",
key: unit.key,
});
resetMessages();
setIsEditUnitOpen(true);
};
const openCreatePositionDialog = async (
mode: "create-root" | "create-child",
unitId = selectedUnit?.id,
) => {
if (!unitId) {
setActionError("Select a unit before creating a department.");
return;
}
resetMessages();
try {
await loadPositionTypes(unitId);
setCreatePositionMode(mode);
setPositionForm({
...emptyPositionForm,
parentPositionId: mode === "create-child" && selectedPosition ? selectedPosition.id : "",
});
setIsCreatePositionOpen(true);
} catch (error) {
setActionError(getErrorMessage(error, "Failed to load position types."));
}
};
const openEditPositionDialog = async (position: PositionRecord) => {
if (!selectedUnit) {
setActionError("Select a unit before editing a department.");
return;
}
resetMessages();
try {
await loadPositionTypes(selectedUnit.id);
setPositionForm({
id: position.id,
nameEn: position.name.en ?? "",
nameAm: position.name.am ?? "",
key: position.key,
parentPositionId: position.parentPositionId ?? "",
positionTypeId: position.positionTypeId ?? "",
});
setIsEditPositionOpen(true);
} catch (error) {
setActionError(getErrorMessage(error, "Failed to load position types."));
}
};
const handleCreateUnit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedOrganization) {
return;
}
setSubmitting(true);
resetMessages();
try {
await api.post("/units", {
name: { am: unitForm.nameAm.trim(), en: unitForm.nameEn.trim() },
key: unitForm.key.trim(),
organizationId: selectedOrganization.id,
});
setActionSuccess("Unit created.");
setIsCreateUnitOpen(false);
await refreshSelectedOrg();
} catch (error) {
setActionError(getErrorMessage(error, "Failed to create unit."));
} finally {
setSubmitting(false);
}
};
const handleUpdateUnit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedOrganization || !selectedUnit) {
return;
}
setSubmitting(true);
resetMessages();
try {
await api.put(`/units/${selectedUnit.id}`, {
name: { am: unitForm.nameAm.trim(), en: unitForm.nameEn.trim() },
key: unitForm.key.trim(),
organizationId: selectedOrganization.id,
});
setActionSuccess("Unit updated.");
setIsEditUnitOpen(false);
await refreshSelectedOrg();
} catch (error) {
setActionError(getErrorMessage(error, "Failed to update unit."));
} finally {
setSubmitting(false);
}
};
const handleDeleteUnit = async (unit: UnitRecord) => {
if (!unit) {
return;
}
if (!window.confirm(`Delete unit '${getLocaleLabel(unit.name, unit.key)}'?`)) {
return;
}
setSubmitting(true);
resetMessages();
try {
await api.delete(`/units/${unit.id}`);
setActionSuccess("Unit deleted.");
await refreshSelectedOrg();
} catch (error) {
setActionError(getErrorMessage(error, "Failed to delete unit."));
} finally {
setSubmitting(false);
}
};
const handleSavePosition = async (event: React.FormEvent<HTMLFormElement>, isEditing: boolean) => {
event.preventDefault();
if (!selectedOrganization || !selectedUnit) {
setActionError("Select a unit before managing positions.");
return;
}
if (!positionForm.positionTypeId) {
setActionError("Select a position type before saving the department.");
return;
}
setSubmitting(true);
resetMessages();
try {
const payload = {
id: positionForm.id,
name: { am: positionForm.nameAm.trim(), en: positionForm.nameEn.trim() },
key: positionForm.key.trim(),
unitId: selectedUnit.id,
organizationId: selectedOrganization.id,
parentPositionId: positionForm.parentPositionId || null,
positionTypeId: positionForm.positionTypeId,
rank: 0,
};
if (isEditing && positionForm.id) {
const existing = positions.find((item) => item.id === positionForm.id);
await api.put(`/positions/${positionForm.id}`, payload);
if ((existing?.parentPositionId ?? "") !== (positionForm.parentPositionId || "")) {
await api.patch("/positions/change-parent", {
positionId: positionForm.id,
newParentId: positionForm.parentPositionId || null,
});
}
setActionSuccess("Position updated.");
setIsEditPositionOpen(false);
} else {
await api.post("/positions", payload);
setActionSuccess("Position created.");
setIsCreatePositionOpen(false);
}
await refreshSelectedUnit();
} catch (error) {
setActionError(getErrorMessage(error, "Failed to save position."));
} finally {
setSubmitting(false);
}
};
const handleDeletePosition = async (position: PositionRecord) => {
if (!position) {
return;
}
if (!window.confirm(`Delete position '${getLocaleLabel(position.name, position.key)}'?`)) {
return;
}
setSubmitting(true);
resetMessages();
try {
await api.delete(`/positions/${position.id}`);
setActionSuccess("Position deleted.");
await refreshSelectedUnit();
} catch (error) {
setActionError(getErrorMessage(error, "Failed to delete position."));
} finally {
setSubmitting(false);
}
};
const handleInviteEmployee = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedPosition) {
setActionError("Select a position before inviting an employee.");
return;
}
setSubmitting(true);
resetMessages();
try {
await api.post("/employee-positions/invite", {
positionId: selectedPosition.id,
username: inviteForm.username.trim(),
phoneNumber: inviteForm.phoneNumber.trim(),
email: inviteForm.email.trim(),
name: { am: inviteForm.nameAm.trim(), en: inviteForm.nameEn.trim() },
});
setInviteForm(emptyInviteForm);
setActionSuccess("Employee invited and assigned to the selected position.");
setIsInviteEmployeeOpen(false);
await Promise.all([loadOrgEmployees(selectedOrgId!), loadPositionMembers(selectedPosition.id)]);
} catch (error) {
setActionError(getErrorMessage(error, "Failed to invite employee."));
} finally {
setSubmitting(false);
}
};
const handleAssignEmployee = async (employeeId: string) => {
if (!selectedPosition) {
return;
}
setSubmitting(true);
resetMessages();
try {
await api.post("/employee-positions/assign", {
positionId: selectedPosition.id,
employeeId,
});
setActionSuccess("Employee assigned to the selected position.");
setIsAssignEmployeeOpen(false);
setAssignSearch("");
await Promise.all([loadOrgEmployees(selectedOrgId!), loadPositionMembers(selectedPosition.id)]);
} catch (error) {
setActionError(getErrorMessage(error, "Failed to assign employee."));
} finally {
setSubmitting(false);
}
};
const handleToggleUserActivation = async (employee: EmployeeRecord) => {
if (!employee.user?.id) {
return;
}
const isInactive = employee.status?.toLowerCase() === "inactive";
setSubmitting(true);
resetMessages();
try {
await api.patch(
`/users/${isInactive ? "activate-user" : "deactivate-user"}/${employee.user.id}`,
);
setActionSuccess(isInactive ? "User activated." : "User deactivated.");
await Promise.all([loadOrgEmployees(selectedOrgId!), loadPositionMembers(selectedPositionId)]);
} catch (error) {
setActionError(getErrorMessage(error, "Failed to update user status."));
} finally {
setSubmitting(false);
}
};
const handleAdminRoleMutation = async (
endpoint: string,
payload: { organizationId?: string; unitId?: string; userId: string },
successMessage: string,
) => {
setSubmitting(true);
resetMessages();
try {
await api.post(endpoint, payload);
setActionSuccess(successMessage);
await Promise.all([
selectedOrgId ? loadOrgAdmins(selectedOrgId) : Promise.resolve(),
loadUnitAdmins(selectedUnitId),
]);
} catch (error) {
setActionError(getErrorMessage(error, "Failed to update admin role."));
} finally {
setSubmitting(false);
}
};
const openManageRolesDialog = async (employee: EmployeeRecord) => {
if (!selectedOrgId || !employee.user?.id) {
return;
}
setRolesLoading(true);
resetMessages();
setSelectedRoleUser(employee);
setIsManageRolesOpen(true);
try {
const [rolesResponse, assignedResponse] = await Promise.all([
api.get<ListResponse<RoleRecord>>("/roles"),
api.get<RoleRecord[]>(`/backoffice/organizations/${selectedOrgId}/employee-users/${employee.user.id}/roles`),
]);
const roles = getItems(rolesResponse.data).filter((role) => !RESERVED_ROLE_KEYS.has(role.key));
setAvailableRoles(roles);
setRoleIds(getItems(assignedResponse.data).map((role) => role.id));
} catch (error) {
setActionError(getErrorMessage(error, "Failed to load user roles."));
setAvailableRoles([]);
setRoleIds([]);
} finally {
setRolesLoading(false);
}
};
const handleSaveRoles = async () => {
if (!selectedOrgId || !selectedRoleUser?.user?.id) {
return;
}
setSubmitting(true);
resetMessages();
try {
await api.put(
`/backoffice/organizations/${selectedOrgId}/employee-users/${selectedRoleUser.user.id}/roles`,
{ roleIds },
);
setActionSuccess("User roles updated.");
setIsManageRolesOpen(false);
} catch (error) {
setActionError(getErrorMessage(error, "Failed to update user roles."));
} finally {
setSubmitting(false);
}
};
return (
<section className="space-y-6 bg-background p-6 text-foreground">
<div className="rounded-3xl border border-border bg-linear-to-br from-emerald-100 via-card to-background p-6 shadow-sm dark:from-emerald-950/30 dark:via-card dark:to-background">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300">
<Network className="h-6 w-6" />
</div>
<div className="space-y-2">
<p className="text-sm font-medium uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-300">
User management
</p>
<h1 className="text-2xl font-semibold text-foreground">Organization workspace</h1>
<p className="max-w-3xl text-sm text-muted-foreground">
Manage organizations, branches, departments, team members, and admin access from a
single SmartOffice-style workspace.
</p>
</div>
</div>
<button
type="button"
onClick={() => void handleRefresh()}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
<RefreshCw className="h-4 w-4" />
Refresh
</button>
</div>
</div>
{actionSuccess ? (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-200">
{actionSuccess}
</div>
) : null}
{actionError ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-200">
{actionError}
</div>
) : null}
{loadError ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-200">
{loadError}
</div>
) : null}
{loading ? (
<div className="rounded-3xl border border-border bg-card p-6 text-sm text-muted-foreground shadow-sm">
Loading user management workspace...
</div>
) : (
<div className="grid gap-6 xl:grid-cols-4">
<aside className="rounded-3xl border border-border bg-card p-5 shadow-sm">
<div className="mb-5 flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-card-foreground">Organization</h2>
<p className="text-sm text-muted-foreground">
{isSuperAdmin ? "All organizations" : "Assigned organizations"}
</p>
</div>
</div>
{visibleOrganizations.length ? (
<ul className="space-y-2">
{visibleOrganizations.map((organization) => {
const isActive = selectedOrgId === organization.id;
const isDisabled = !isSuperAdmin;
return (
<li key={organization.id} className="group">
<div
className={`flex w-full items-center gap-2 rounded-2xl border px-4 py-3 text-left transition ${
isActive
? "border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-100"
: "border-border bg-card text-card-foreground hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:bg-slate-900"
} ${isDisabled ? "cursor-default" : ""}`}
>
<button
type="button"
disabled={isDisabled}
onClick={() => void handleSelectOrganization(organization.id)}
className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left"
>
<div className="min-w-0">
<div className="truncate font-medium">
{getLocaleLabel(organization.name, organization.key)}
</div>
<div className="truncate text-xs text-muted-foreground">{organization.key}</div>
</div>
{!isSuperAdmin ? (
<Badge className="bg-sky-100 text-sky-700">Assigned</Badge>
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={submitting}
onClick={(event) => event.stopPropagation()}
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-border bg-card text-card-foreground opacity-0 transition group-hover:opacity-100 group-focus-within:opacity-100 hover:bg-accent hover:text-accent-foreground"
aria-label="Organization actions"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
void handleSelectOrganization(organization.id);
openCreateUnitDialog();
}}
>
Create unit
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
) : (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
No organizations available.
</div>
)}
</aside>
<aside className="rounded-3xl border border-border bg-card p-5 shadow-sm">
<div className="mb-5 flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-card-foreground">Units</h2>
<p className="text-sm text-muted-foreground">
{selectedOrganization
? getLocaleLabel(selectedOrganization.name, selectedOrganization.key)
: "Select an organization"}
</p>
</div>
<div className="rounded-full border border-border bg-muted px-3 py-1 text-sm font-medium text-muted-foreground">
{`${units.length}/${unitsAllowedLabel}`}
</div>
</div>
<div className="relative mb-4">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
className={`${inputClassName} pl-9`}
value={unitSearch}
onChange={(event) => setUnitSearch(event.target.value)}
placeholder="Search units"
/>
</div>
{filteredUnits.length ? (
<ul className="space-y-2">
{filteredUnits.map((item) => {
const isActive = selectedUnit?.id === item.id;
return (
<li key={item.id} className="group">
<div
className={`flex w-full items-center gap-2 rounded-2xl border px-4 py-3 text-left transition ${
isActive
? "border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-100"
: "border-border bg-card text-card-foreground hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:bg-slate-900"
}`}
>
<button
type="button"
onClick={() => void handleSelectUnit(item.id)}
className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left"
>
<div className="min-w-0">
<div className="truncate font-medium">{getLocaleLabel(item.name, item.key)}</div>
<div className="truncate text-xs text-muted-foreground">{item.key}</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={submitting}
onClick={(event) => event.stopPropagation()}
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-border bg-card text-card-foreground opacity-0 transition group-hover:opacity-100 group-focus-within:opacity-100 hover:bg-accent hover:text-accent-foreground"
aria-label="Unit actions"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
void (async () => {
await handleSelectUnit(item.id);
await openCreatePositionDialog("create-root", item.id);
})();
}}
>
Add Department
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
setSelectedUnitId(item.id);
openEditUnitDialog(item);
}}
>
Edit Unit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={(event) => {
event.preventDefault();
setSelectedUnitId(item.id);
void handleDeleteUnit(item);
}}
>
Delete Unit
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
) : (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
{selectedOrganization ? "No units found." : "Select an organization to load units."}
</div>
)}
</aside>
<aside className="rounded-3xl border border-border bg-card p-5 shadow-sm">
<div className="mb-5 flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-card-foreground">Departments</h2>
<p className="text-sm text-muted-foreground">
{selectedUnit ? getLocaleLabel(selectedUnit.name, selectedUnit.key) : "Select a unit"}
</p>
</div>
</div>
<div className="relative mb-4">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
className={`${inputClassName} pl-9`}
value={positionSearch}
onChange={(event) => setPositionSearch(event.target.value)}
placeholder="Search departments"
/>
</div>
{filteredPositionTree.length ? (
<ul className="space-y-2">
{filteredPositionTree.map((node) => (
<TreeNode
key={node.id}
node={node}
selectedPositionId={selectedPositionId}
onSelect={(position) => setSelectedPositionId(position.id)}
expandedDepartmentIds={expandedDepartmentIds}
onToggleExpand={handleToggleDepartment}
onInviteUser={openInviteForDepartment}
onAssignUser={openAssignForDepartment}
onAddSubDepartment={openCreateSubDepartmentDialog}
onEdit={(position) => {
setSelectedPositionId(position.id);
void openEditPositionDialog(position);
}}
onDelete={(position) => {
setSelectedPositionId(position.id);
void handleDeletePosition(position);
}}
disabled={!selectedUnit || submitting}
/>
))}
</ul>
) : (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
{selectedUnit ? "No departments found." : "Select a unit to load departments."}
</div>
)}
</aside>
<section className="rounded-3xl border border-border bg-card p-5 shadow-sm">
<div className="mb-5">
<div>
<h2 className="text-lg font-semibold text-card-foreground">Team members</h2>
<p className="text-sm text-muted-foreground">
{selectedPosition
? `${getLocaleLabel(selectedPosition.name, selectedPosition.key)} in ${getLocaleLabel(selectedUnit?.name, selectedUnit?.key ?? "")}`
: "Select a department"}
</p>
</div>
</div>
<div className="relative mb-4">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
className={`${inputClassName} pl-9`}
value={employeeSearch}
onChange={(event) => setEmployeeSearch(event.target.value)}
placeholder="Search team members"
/>
</div>
{membersLoading ? (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
Loading team members...
</div>
) : filteredPositionMembers.length ? (
<div className="space-y-3">
{filteredPositionMembers.map((employee) => {
const userId = employee.user?.id;
const isOrgAdmin = Boolean(userId && orgAdminUserIds.has(userId));
const isUnitAdmin = Boolean(userId && unitAdminUserIds.has(userId));
const displayName = getLocaleLabel(
employee.name ?? employee.user?.name,
employee.user?.email ?? employee.id,
);
const assignedPositions = employee.employeePositions
?.map((position) => getLocaleLabel(position.position?.name, position.position?.id ?? ""))
.filter(Boolean)
.join(", ");
return (
<article
key={employee.id}
className="rounded-2xl border border-border bg-background p-4 shadow-sm"
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-semibold text-foreground">{displayName}</h3>
{isOrgAdmin ? (
<Badge className="bg-green-100 text-green-700">Org admin</Badge>
) : null}
{isUnitAdmin ? (
<Badge className="bg-sky-100 text-sky-700">Unit admin</Badge>
) : null}
{employee.status ? (
<Badge className="bg-gray-100 text-gray-700">{employee.status}</Badge>
) : null}
</div>
<div className="text-sm text-muted-foreground">
{employee.user?.email || employee.user?.username || "No contact info"}
</div>
{assignedPositions ? (
<div className="text-sm text-muted-foreground">
Current positions: {assignedPositions}
</div>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={!userId || submitting}
onClick={() => void handleToggleUserActivation(employee)}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
{employee.status?.toLowerCase() === "inactive" ? (
<>
<UserCheck className="h-4 w-4" />
Activate
</>
) : (
<>
<UserMinus className="h-4 w-4" />
Deactivate
</>
)}
</button>
<button
type="button"
disabled={!userId || !selectedOrgId || submitting}
onClick={() =>
void handleAdminRoleMutation(
isOrgAdmin
? "/user-roles/remove-org-admin-role"
: "/user-roles/assign-org-admin-role",
{
organizationId: selectedOrgId ?? undefined,
userId: userId!,
},
isOrgAdmin ? "Organization admin removed." : "Organization admin assigned.",
)
}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
<ShieldCheck className="h-4 w-4" />
{isOrgAdmin ? "Remove org admin" : "Make org admin"}
</button>
<button
type="button"
disabled={!userId || !selectedUnitId || submitting}
onClick={() =>
void handleAdminRoleMutation(
isUnitAdmin
? "/user-roles/remove-unit-admin-role"
: "/user-roles/assign-unit-admin-role",
{
unitId: selectedUnitId ?? undefined,
userId: userId!,
},
isUnitAdmin ? "Unit admin removed." : "Unit admin assigned.",
)
}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
<ShieldCheck className="h-4 w-4" />
{isUnitAdmin ? "Remove unit admin" : "Make unit admin"}
</button>
<button
type="button"
disabled={!userId || !selectedOrgId || submitting}
onClick={() => void openManageRolesDialog(employee)}
className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}
>
<Users className="h-4 w-4" />
Manage roles
</button>
</div>
</div>
</article>
);
})}
</div>
) : (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
No team members found for this department.
</div>
)}
</section>
</div>
)}
<ManagementDialog
open={isCreateUnitOpen}
onOpenChange={setIsCreateUnitOpen}
title="Create unit"
description="Add a new branch under the selected organization."
>
<form className="space-y-4" onSubmit={handleCreateUnit}>
<Field label="English name">
<input
className={inputClassName}
value={unitForm.nameEn}
onChange={(event) => setUnitForm((current) => ({ ...current, nameEn: event.target.value }))}
/>
</Field>
<Field label="Amharic name">
<input
className={inputClassName}
value={unitForm.nameAm}
onChange={(event) => setUnitForm((current) => ({ ...current, nameAm: event.target.value }))}
/>
</Field>
<Field label="Key">
<div className="flex gap-2">
<input
className={inputClassName}
value={unitForm.key}
onChange={(event) => setUnitForm((current) => ({ ...current, key: event.target.value }))}
/>
<button
type="button"
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
onClick={() => setUnitForm((current) => ({ ...current, key: toInternalKey(current.nameEn) }))}
>
Generate
</button>
</div>
</Field>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsCreateUnitOpen(false)}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
Cancel
</button>
<button type="submit" disabled={submitting} className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}>
Save unit
</button>
</div>
</form>
</ManagementDialog>
<ManagementDialog
open={isEditUnitOpen}
onOpenChange={setIsEditUnitOpen}
title="Edit unit"
description="Update the selected branch."
>
<form className="space-y-4" onSubmit={handleUpdateUnit}>
<Field label="English name">
<input
className={inputClassName}
value={unitForm.nameEn}
onChange={(event) => setUnitForm((current) => ({ ...current, nameEn: event.target.value }))}
/>
</Field>
<Field label="Amharic name">
<input
className={inputClassName}
value={unitForm.nameAm}
onChange={(event) => setUnitForm((current) => ({ ...current, nameAm: event.target.value }))}
/>
</Field>
<Field label="Key">
<input
className={inputClassName}
value={unitForm.key}
onChange={(event) => setUnitForm((current) => ({ ...current, key: event.target.value }))}
/>
</Field>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsEditUnitOpen(false)}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
Cancel
</button>
<button type="submit" disabled={submitting} className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}>
Save changes
</button>
</div>
</form>
</ManagementDialog>
<ManagementDialog
open={isCreatePositionOpen}
onOpenChange={setIsCreatePositionOpen}
title={createPositionMode === "create-root" ? "Create root position" : "Create child position"}
description="Add a department or sub-department under the selected branch."
>
<form className="space-y-4" onSubmit={(event) => void handleSavePosition(event, false)}>
<Field label="English name">
<input
className={inputClassName}
value={positionForm.nameEn}
onChange={(event) =>
setPositionForm((current) => ({ ...current, nameEn: event.target.value }))
}
/>
</Field>
<Field label="Amharic name">
<input
className={inputClassName}
value={positionForm.nameAm}
onChange={(event) =>
setPositionForm((current) => ({ ...current, nameAm: event.target.value }))
}
/>
</Field>
<Field label="Key">
<div className="flex gap-2">
<input
className={inputClassName}
value={positionForm.key}
onChange={(event) => setPositionForm((current) => ({ ...current, key: event.target.value }))}
/>
<button
type="button"
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
onClick={() => setPositionForm((current) => ({ ...current, key: toInternalKey(current.nameEn) }))}
>
Generate
</button>
</div>
</Field>
<Field label="Position type">
<Select
value={positionForm.positionTypeId || undefined}
onValueChange={(value) =>
setPositionForm((current) => ({ ...current, positionTypeId: value }))
}
disabled={positionTypesLoading || submitting}
>
<SelectTrigger className="w-full rounded-xl">
<SelectValue
placeholder={positionTypesLoading ? "Loading position types..." : "Select position type"}
/>
</SelectTrigger>
<SelectContent>
{positionTypes.map((positionType) => (
<SelectItem key={positionType.id} value={positionType.id}>
{getLocaleLabel(positionType.name, positionType.key)}
</SelectItem>
))}
</SelectContent>
</Select>
{positionTypesError ? (
<p className="mt-2 text-xs text-destructive">{positionTypesError}</p>
) : null}
</Field>
<Field label="Parent position">
<select
className={inputClassName}
value={positionForm.parentPositionId}
onChange={(event) =>
setPositionForm((current) => ({ ...current, parentPositionId: event.target.value }))
}
>
<option value="">No parent</option>
{parentOptions.map((position) => (
<option key={position.id} value={position.id}>
{getLocaleLabel(position.name, position.key)}
</option>
))}
</select>
</Field>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsCreatePositionOpen(false)}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
Cancel
</button>
<button type="submit" disabled={submitting} className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}>
Save position
</button>
</div>
</form>
</ManagementDialog>
<ManagementDialog
open={isEditPositionOpen}
onOpenChange={setIsEditPositionOpen}
title="Edit position"
description="Update the selected department or move it under a different parent."
>
<form className="space-y-4" onSubmit={(event) => void handleSavePosition(event, true)}>
<Field label="English name">
<input
className={inputClassName}
value={positionForm.nameEn}
onChange={(event) =>
setPositionForm((current) => ({ ...current, nameEn: event.target.value }))
}
/>
</Field>
<Field label="Amharic name">
<input
className={inputClassName}
value={positionForm.nameAm}
onChange={(event) =>
setPositionForm((current) => ({ ...current, nameAm: event.target.value }))
}
/>
</Field>
<Field label="Key">
<input
className={inputClassName}
value={positionForm.key}
onChange={(event) => setPositionForm((current) => ({ ...current, key: event.target.value }))}
/>
</Field>
<Field label="Position type">
<Select
value={positionForm.positionTypeId || undefined}
onValueChange={(value) =>
setPositionForm((current) => ({ ...current, positionTypeId: value }))
}
disabled={positionTypesLoading || submitting}
>
<SelectTrigger className="w-full rounded-xl">
<SelectValue
placeholder={positionTypesLoading ? "Loading position types..." : "Select position type"}
/>
</SelectTrigger>
<SelectContent>
{positionTypes.map((positionType) => (
<SelectItem key={positionType.id} value={positionType.id}>
{getLocaleLabel(positionType.name, positionType.key)}
</SelectItem>
))}
</SelectContent>
</Select>
{positionTypesError ? (
<p className="mt-2 text-xs text-destructive">{positionTypesError}</p>
) : null}
</Field>
<Field label="Parent position">
<select
className={inputClassName}
value={positionForm.parentPositionId}
onChange={(event) =>
setPositionForm((current) => ({ ...current, parentPositionId: event.target.value }))
}
>
<option value="">No parent</option>
{parentOptions.map((position) => (
<option key={position.id} value={position.id}>
{getLocaleLabel(position.name, position.key)}
</option>
))}
</select>
</Field>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsEditPositionOpen(false)}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
Cancel
</button>
<button type="submit" disabled={submitting} className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}>
Save changes
</button>
</div>
</form>
</ManagementDialog>
<ManagementDialog
open={isInviteEmployeeOpen}
onOpenChange={setIsInviteEmployeeOpen}
title="Invite employee"
description="Create and assign a new employee to the selected position."
>
<form className="space-y-4" onSubmit={handleInviteEmployee}>
<Field label="English name">
<input
className={inputClassName}
value={inviteForm.nameEn}
onChange={(event) => setInviteForm((current) => ({ ...current, nameEn: event.target.value }))}
/>
</Field>
<Field label="Amharic name">
<input
className={inputClassName}
value={inviteForm.nameAm}
onChange={(event) => setInviteForm((current) => ({ ...current, nameAm: event.target.value }))}
/>
</Field>
<Field label="Email">
<input
className={inputClassName}
type="email"
value={inviteForm.email}
onChange={(event) => setInviteForm((current) => ({ ...current, email: event.target.value }))}
/>
</Field>
<Field label="Username">
<input
className={inputClassName}
value={inviteForm.username}
onChange={(event) => setInviteForm((current) => ({ ...current, username: event.target.value }))}
/>
</Field>
<Field label="Phone number">
<input
className={inputClassName}
value={inviteForm.phoneNumber}
onChange={(event) =>
setInviteForm((current) => ({ ...current, phoneNumber: event.target.value }))
}
/>
</Field>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsInviteEmployeeOpen(false)}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
Cancel
</button>
<button type="submit" disabled={submitting} className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}>
Send invite
</button>
</div>
</form>
</ManagementDialog>
<ManagementDialog
open={isAssignEmployeeOpen}
onOpenChange={setIsAssignEmployeeOpen}
title="Assign existing employee"
description="Browse all employees in the selected organization and assign one to the selected position."
>
<div className="space-y-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
className={`${inputClassName} pl-9`}
value={assignSearch}
onChange={(event) => setAssignSearch(event.target.value)}
placeholder="Search organization employees"
/>
</div>
<div className="max-h-[420px] space-y-2 overflow-y-auto pr-1">
{orgEmployeesLoading ? (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
Loading organization employees...
</div>
) : assignableEmployees.length ? (
assignableEmployees.map((employee) => {
const displayName = getLocaleLabel(
employee.name ?? employee.user?.name,
employee.user?.email ?? employee.id,
);
const assignedPositions = employee.employeePositions
?.map((position) => getLocaleLabel(position.position?.name, position.position?.id ?? ""))
.filter(Boolean)
.join(", ");
return (
<div
key={employee.id}
className="flex items-start justify-between gap-3 rounded-2xl border border-border bg-background p-4"
>
<div className="space-y-1">
<div className="font-medium text-foreground">{displayName}</div>
<div className="text-sm text-muted-foreground">
{employee.user?.email || employee.user?.phoneNumber || "No contact info"}
</div>
{assignedPositions ? (
<div className="text-sm text-muted-foreground">
Existing positions: {assignedPositions}
</div>
) : null}
</div>
<button
type="button"
disabled={submitting}
onClick={() => void handleAssignEmployee(employee.id)}
className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Assign
</button>
</div>
);
})
) : (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
No employees found for this organization.
</div>
)}
</div>
</div>
</ManagementDialog>
<ManagementDialog
open={isManageRolesOpen}
onOpenChange={setIsManageRolesOpen}
title="Manage roles"
description={
selectedRoleUser
? `Assign organization-scoped roles for ${getLocaleLabel(selectedRoleUser.name ?? selectedRoleUser.user?.name, selectedRoleUser.user?.email ?? selectedRoleUser.id)}.`
: undefined
}
>
<div className="space-y-4">
{rolesLoading ? (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
Loading roles...
</div>
) : (
<div className="max-h-[420px] space-y-2 overflow-y-auto pr-1">
{availableRoles.map((role) => (
<label
key={role.id}
className="flex items-center gap-3 rounded-2xl border border-border bg-background px-4 py-3 text-sm"
>
<input
type="checkbox"
checked={roleIds.includes(role.id)}
onChange={(event) => {
setRoleIds((current) =>
event.target.checked
? [...current, role.id]
: current.filter((currentRoleId) => currentRoleId !== role.id),
);
}}
/>
<div>
<div className="font-medium text-foreground">{getLocaleLabel(role.name, role.key)}</div>
<div className="text-xs text-muted-foreground">{role.key}</div>
</div>
</label>
))}
{!availableRoles.length ? (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
No assignable roles available.
</div>
) : null}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsManageRolesOpen(false)}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
Cancel
</button>
<button
type="button"
disabled={submitting || rolesLoading}
onClick={() => void handleSaveRoles()}
className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Save roles
</button>
</div>
</div>
</ManagementDialog>
</section>
);
};
export default UserManagementPage;