mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
2284 lines
81 KiB
TypeScript
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;
|