Files
edr-platform/apps/edr-freight-web/backoffice/src/user-management/userManagement/UserManagementTree.tsx
natib21 0d98ccb04b fix
2026-07-17 13:10:27 +00:00

1174 lines
48 KiB
TypeScript

import { useEffect, useState } from "react";
import { Input } from "@/shared/common/ui/input";
import {
Search,
ChevronLeft,
ChevronRight,
Download,
RefreshCw,
Mail,
X,
} from "lucide-react";
import { useToast } from "@/shared/common/ui/use-toast";
import axiosInstance from "@/shared/services/axiosInstance";
import { toast } from "sonner";
import { Button } from "@/shared/common/ui/button";
import { MultiSelect } from "@/shared/common/ui/multi-select";
import { withHeaders } from "@/record-management/services/api/withHeaders";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { SingleSelect } from "@/shared/common/ui/single-select";
import { OrganizationList } from "./lists/OrganizationList";
import { UnitList } from "./lists/UnitList";
import { DepartmentList } from "./lists/DepartmentList";
import { TeamMembers } from "./TeamMembers";
import { AddDepartmentDialog } from "./dialogs/AddDepartmentDialog";
import { AddUserDialog } from "./dialogs/AddUserDialog";
import { ViewUsersDialog } from "./dialogs/ViewUsersDialog";
import { AddSubDepartmentDialog } from "./dialogs/AddSubDepartmentDialog";
import { AddAssignUserDialog } from "./dialogs/AddAssignUserDialog";
import { useOrganizationsData } from "./hooks/useOrganizationsData";
import { useUnitsData } from "./hooks/useUnitsData";
import { useDepartmentsData } from "./hooks/useDepartmentsData";
import { useEmployeesData } from "./hooks/useEmployeesData";
import { useSelectionHandlers } from "./handlers/useSelectionHandlers";
import { useActionHandlers } from "./handlers/useActionHandlers";
import { Breadcrumb } from "./components/Breadcrumb";
import { AddUserUnderDepartmentDialog } from "./dialogs/AddUserUnderDepartmentDialog";
import { useAuth } from "@/shared/context/AuthContext";
import { EditDepartmentDialog } from "./dialogs/EditDepartmentDialog";
import { EditUnitDialog } from "./dialogs/EditUnitDialog";
import { DeleteUnitDialog } from "./dialogs/DeleteUnitDialog";
import { PermanentDeleteUnitDialog } from "./dialogs/PermanentDeleteUnitDialog";
import { AddFromSubCityDialog } from "./dialogs/AddFromSubCityDialog";
import { UnitSelectionModal } from "./components/UnitSelectionModal";
import { AddUnitDialog } from "./dialogs/AddUnitDialog";
import { Plus } from "lucide-react";
import { DeleteDepartmentDialog } from "./dialogs/DeleteDepartmentDialog";
import { DeleteTeamMemberDialog } from "./dialogs/DeleteTeamMemberDialog";
import { TeamMemberDto } from "../dto/teamMember/teamMember";
import { useRef } from "react";
import { UserCircle } from "lucide-react";
import { resendVerificationCode } from "@/shared/services/authService";
import { useLocalizedName } from "@/shared/common/localizedName";
import { MoveDepartmentDialog } from "./dialogs/MoveDepartmentDialog";
import i18n from "@/i18n";
import { t } from "i18next";
import { useExportUserData } from "@/shared/hooks/useExportUserData";
import { DeactivateTeamMemberDialog } from "./dialogs/DeactivateTeamMemberDialog";
import { OrganizationEmployeeSearch } from "./components/OrganizationEmployeeSearch";
import { useGetOrganizationConfig } from "@/super-admin/hooks/useConfig";
import { useUnit } from "@/user-management/hooks/useUnit";
import { useArchiveActions } from "@/user-management/hooks/useArchived";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { ManageUnitAdminDialog } from "./dialogs/ManageUnitAdminDialog";
const UserManagementTree = () => {
const { toast: uiToast } = useToast();
const { softDeletePosition } = useArchiveActions();
const handleArchiveDepartment = (departmentId: string) => {
softDeletePosition(departmentId);
};
const [searchQuery, setSearchQuery] = useState("");
const [unitSearchQuery, setUnitSearchQuery] = useState("");
const [departmentSearchQuery, setDepartmentSearchQuery] = useState("");
const [employeeSearchQuery, setEmployeeSearchQuery] = useState("");
const { user } = useAuth();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const localizedName = useLocalizedName();
const lang = i18n.language;
// Resend invitation states
const [showResendModal, setShowResendModal] = useState(false);
const [showConfirmAllModal, setShowConfirmAllModal] = useState(false);
const [pendingUsers, setPendingUsers] = useState<any[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const [isSendingInvitations, setIsSendingInvitations] = useState(false);
const [isLoadingPending, setIsLoadingPending] = useState(false);
const isOrganizationAdmin =
user?.roles?.some((role) => role.key === "organization_admin") ?? false;
// Export functionality
const {
exportUserData,
getUnits,
isExporting,
isLoadingUnits,
units: exportUnits,
} = useExportUserData();
const [selectedExportUnitId, setSelectedExportUnitId] = useState<string>("");
const [showUnitSelection, setShowUnitSelection] = useState(false);
const [exportFormat, setExportFormat] = useState<"xlsx" | "csv">("xlsx");
const [showEmployeeSearch, setShowEmployeeSearch] = useState(false);
const [showSendToUnitModal, setShowSendToUnitModal] = useState(false);
const [selectedSendUnitId, setSelectedSendUnitId] = useState<string>("");
const { handleError } = useErrorHandler(t);
// Hooks for data loading
const organizationId =
user?.employee && user.employee.length > 0
? user.employee[0].organizationId
: undefined;
const { organizations, isLoading: orgsLoading } = useOrganizationsData({
currentOrgId: organizationId,
});
const {
selectedOrgId,
selectedUnitId,
selectedDepartmentId,
selectedDepartmentName,
breadcrumb,
handleSelectOrganization,
handleSelectUnit,
handleSelectDepartment,
} = useSelectionHandlers(organizations, localizedName);
const [take] = useState(100);
const [skip, setSkip] = useState(0);
const {
units,
isLoading: unitsLoading,
setUnits,
fetchedUnits,
} = useUnitsData(selectedOrgId, { take, skip });
const { data: orgConfigResp } = useGetOrganizationConfig(selectedOrgId || "");
const unitCapacity: number | null = (() => {
const items = orgConfigResp?.data?.items ?? [];
if (!Array.isArray(items) || items.length === 0) return null;
const match =
items.find((it: any) => it?.organizationId === selectedOrgId) ?? items[0];
const value = Number(match?.maximumNumberOfUnits);
return Number.isFinite(value) ? value : null;
})();
// Pull child units (units related under one of this org's units) so they
// show up alongside the top-level units in the column. Track the expanded
// parent separately so clicking on a child (or its action menu) doesn't
// collapse the sibling list.
const { getChildren } = useUnit();
const [expandedParentId, setExpandedParentId] = useState<string>("");
useEffect(() => {
if (!selectedUnitId) return;
const isTopLevel = units.some((u) => u.id === selectedUnitId);
if (isTopLevel) setExpandedParentId(selectedUnitId);
}, [selectedUnitId, units]);
useEffect(() => {
setExpandedParentId("");
}, [selectedOrgId]);
const { data: childUnitsResp } = getChildren(expandedParentId || "");
const childUnits = (() => {
const data = childUnitsResp?.data;
if (!data) return [] as Array<{ id: string; name: any }>;
const items = Array.isArray(data) ? data : (data.items ?? []);
return Array.isArray(items)
? items.map((u: any) => ({ id: u.id, name: u.name }))
: [];
})();
const childUnitIds = new Set(childUnits.map((u) => u.id));
console.log(units, childUnitIds);
const mergedUnits = (() => {
const seen = new Set<string>();
const out: Array<{ id: string; name: any; isRelated: boolean }> = [];
[...units, ...childUnits].forEach((u) => {
if (u?.id && !seen.has(u.id)) {
seen.add(u.id);
out.push({ ...u, isRelated: childUnitIds.has(u.id) });
}
});
return out;
})();
const [addUnitDialogOpen, setAddUnitDialogOpen] = useState(false);
const canCreateUnit =
unitCapacity !== null && units.length < unitCapacity && !!selectedOrgId;
const {
departments,
isLoading: deptsLoading,
setDepartments,
positions,
} = useDepartmentsData(selectedUnitId);
const {
employees,
isLoading: employeesLoading,
setEmployees,
refetch: refetchEmployees,
} = useEmployeesData(selectedDepartmentId);
// All dialog states & handlers bundled
const actionHandlers = useActionHandlers({
selectedOrgId,
selectedUnitId,
toast: uiToast,
});
// Handle inviting or resending invitation to employees
const handleInviteEmployee = async (employee: TeamMemberDto) => {
try {
// A member who already set a password is being sent a fresh set-password
// code — that is a reset, not an invite, so say so. Keyed off
// hasSetPassword (the same fact the menu label uses) rather than the
// looser `status` field, so the wording cannot disagree with the menu.
const isReset = employee.user.hasSetPassword;
toast.loading(
isReset ? "Sending password reset link..." : "Resending verification code...",
);
await resendVerificationCode({
email: employee.user.email,
phoneNumber: employee.user.phoneNumber,
});
// Show success notification with more specific message for resend
const userName =
typeof employee.user.name === "string"
? localizedName(employee.user.name)
: localizedName(employee.user.name) || "User";
if (isReset) {
toast.success("Password Reset Link Sent", {
description: `${userName} can now set a new password. Any earlier code they had no longer works.`,
duration: 4000,
});
} else {
toast.success("Verification Code Resent", {
description: `A new verification code has been sent to ${userName}`,
duration: 4000,
});
}
// Refresh the employee list to update statuses
if (refetchEmployees) {
await refetchEmployees();
}
} catch (error: unknown) {
// Show error notification
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
toast.error("Error", {
description: `Failed to ${
employee.status === "pending"
? "resend verification code"
: "send invitation"
}: ${errorMessage}`,
duration: 4000,
});
}
setTimeout(() => {
toast.dismiss();
}, 4000);
};
const fetchPendingUsers = async () => {
try {
setIsLoadingPending(true);
// First fetch to get the total count
const countResponse = await axiosInstance.get(
"/employees/my-unit/pending",
{
params: { take: 10 },
headers: withHeaders(),
},
);
const totalCount = countResponse.data?.count ?? 0;
// Fetch all pending users using the total count
const response = await axiosInstance.get("/employees/my-unit/pending", {
params: { take: totalCount || 3000 },
headers: withHeaders(),
});
const data = response.data;
const usersList = Array.isArray(data)
? data
: Array.isArray(data?.items)
? data.items
: [];
setPendingUsers(usersList);
} catch (err: any) {
handleError(err);
} finally {
setIsLoadingPending(false);
}
};
useEffect(() => {
if (showResendModal) {
fetchPendingUsers();
}
}, [showResendModal]);
const handleResendToAll = async () => {
try {
setIsSendingInvitations(true);
toast.loading("Resending verification to all pending users...");
await axiosInstance.post(
"/employees/my-unit/send-verification-to-all-pending",
{},
{ headers: withHeaders() },
);
toast.dismiss();
toast.success("Successfully resent verification to all pending users.");
if (refetchEmployees) {
await refetchEmployees();
}
} catch (err: any) {
toast.dismiss();
handleError(err);
} finally {
setIsSendingInvitations(false);
setShowConfirmAllModal(false);
}
};
const handleSendSelectedInvitations = async () => {
if (selectedUserIds.length === 0) return;
try {
setIsSendingInvitations(true);
toast.loading(
`Sending verification to ${selectedUserIds.length} user(s)...`,
);
await axiosInstance.post(
"/employees/my-unit/generate-verification-codes",
{ employeeIds: selectedUserIds },
{ headers: withHeaders() },
);
toast.dismiss();
toast.success("Successfully sent verification codes.");
setShowResendModal(false);
setSelectedUserIds([]);
if (refetchEmployees) {
await refetchEmployees();
}
} catch (err: any) {
toast.dismiss();
handleError(err);
} finally {
setIsSendingInvitations(false);
}
};
const handleSendToSelectedUnits = async () => {
if (!selectedSendUnitId) return;
try {
toast.loading("Sending verification to unit...");
await axiosInstance.post(
`/employees/unit/${selectedSendUnitId}/send-verification-to-all-pending`,
{},
{ headers: withHeaders() },
);
toast.dismiss();
toast.success("Successfully sent verification to selected unit.");
setShowSendToUnitModal(false);
setSelectedSendUnitId("");
if (refetchEmployees) {
await refetchEmployees();
}
} catch (err: any) {
toast.dismiss();
handleError(err);
}
};
const scrollLeft = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollBy({ left: -200, behavior: "smooth" });
}
};
const scrollRight = () => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollBy({ left: 200, behavior: "smooth" });
}
};
// Export handlers
const handleExportUserData = async () => {
if (!organizationId) {
toast.error(t("organizationIdRequired"));
return;
}
try {
const unitsList = await getUnits(organizationId);
if (unitsList && unitsList.length > 0) {
setShowUnitSelection(true);
} else {
toast.error(t("noUnitsFound"));
}
} catch (error) {
console.error("Failed to load units:", error);
toast.error(t("exportError"));
}
};
const handleUnitSelect = async (unitId: string) => {
setSelectedExportUnitId(unitId);
setShowUnitSelection(false);
// Export data for selected unit
const result = await exportUserData(unitId, exportFormat);
// Reset selectedExportUnitId after export (successful or failed)
setSelectedExportUnitId("");
};
return (
<div className="h-full flex flex-col">
<div className="p-4 border-b dark:border-gray-700">
<div className="flex justify-between items-center mb-4">
<h1 className="text-xl font-semibold text-gray-800 dark:text-gray-200">
{t("organization.userManagement")}
</h1>
<div className="flex gap-2">
{/* Format selection */}
<Select
value={exportFormat}
onValueChange={(value: "xlsx" | "csv") => setExportFormat(value)}
>
<SelectTrigger className="w-32 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600">
<SelectValue />
</SelectTrigger>
<SelectContent className="dark:bg-gray-700 dark:border-gray-600">
<SelectItem
value="xlsx"
className="dark:text-gray-200 dark:hover:bg-gray-600"
>
XLSX
</SelectItem>
<SelectItem
value="csv"
className="dark:text-gray-200 dark:hover:bg-gray-600"
>
CSV
</SelectItem>
</SelectContent>
</Select>
{/* Export button */}
<Button
variant="outline"
size="sm"
onClick={handleExportUserData}
disabled={isExporting || !organizationId}
className="bg-primary-50 text-primary-700 border-primary-200 hover:bg-primary-100 dark:bg-primary-900/50 dark:text-primary-300 dark:border-primary-700 dark:hover:bg-primary-900/70"
>
<Download
className={`h-4 w-4 mr-2 ${isExporting ? "animate-pulse" : ""}`}
/>
{isExporting ? t("exporting") : t("exportUserData")}
</Button>
</div>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500 dark:text-gray-400" />
<Input
placeholder="Search organizations, units, departments..."
className="pl-8 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowEmployeeSearch(true)}
disabled={!organizationId}
className="bg-primary-50 text-primary-700 border-primary-200 hover:bg-primary-100 whitespace-nowrap dark:bg-primary-900/30 dark:text-primary-400 dark:border-primary-800 dark:hover:bg-primary-900/50"
>
<UserCircle className="h-4 w-4 mr-2" />
{t("search.searchAllEmployees")}
</Button>
</div>
<div className="mt-4">
<Breadcrumb
breadcrumb={breadcrumb}
hasTeamMembers={selectedDepartmentId != "" && employees.length > 0}
/>
</div>
{selectedOrgId && (
<div className="mt-3 flex gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => setShowConfirmAllModal(true)}
disabled={isSendingInvitations}
className="bg-orange-50 text-orange-700 border-orange-200 hover:bg-orange-100 dark:bg-orange-900/30 dark:text-orange-400 dark:border-orange-800 dark:hover:bg-orange-900/50"
>
<RefreshCw
className={`h-4 w-4 mr-2 ${isSendingInvitations ? "animate-spin" : ""}`}
/>
{t("userManagement.resendToAll", "Resend Invitation to All")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowResendModal(true)}
disabled={isSendingInvitations}
className="border border-primary-200 bg-primary-50 text-primary-700 hover:border-primary-300 hover:bg-primary-100 dark:border-primary-900 dark:bg-primary-950 dark:text-primary-200 dark:hover:border-primary-700 dark:hover:bg-primary-900 dark:hover:text-white"
>
<Mail className="h-4 w-4 mr-2" />
{t("userManagement.resendInvitation", "Resend Invitation")}
</Button>
{isOrganizationAdmin && (
<Button
variant="outline"
size="sm"
onClick={() => setShowSendToUnitModal(true)}
disabled={isSendingInvitations}
className="border border-green-200 bg-green-50 text-green-700 hover:border-green-300 hover:bg-green-100 dark:border-green-900 dark:bg-green-950 dark:text-green-200 dark:hover:border-green-700 dark:hover:bg-green-900 dark:hover:text-white"
>
<Mail className="h-4 w-4 mr-2" />
{t(
"userManagement.sendToSelectedUnit",
"Send to Selected Unit",
)}
</Button>
)}
</div>
)}
</div>
{/* Mobile view - horizontal scrollable cards */}
<div className="flex-1 p-2 md:hidden">
<div className="relative h-[calc(100vh-180px)]">
<button
onClick={scrollLeft}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-white dark:bg-gray-800 rounded-full shadow-md p-1"
aria-label="Scroll left"
>
<ChevronLeft className="h-5 w-5 dark:text-gray-300" />
</button>
<div
ref={scrollContainerRef}
className="flex overflow-x-auto gap-3 pb-4 snap-x snap-mandatory h-full scrollbar-hide"
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
>
<div className="border rounded-md p-3 bg-white dark:bg-gray-800 shadow-sm min-w-[90%] max-w-[90%] flex-shrink-0 snap-center h-full flex flex-col dark:border-gray-700">
<h3 className="font-medium text-gray-700 dark:text-gray-200 mb-3 border-b pb-2 dark:border-gray-700">
{t("organization.organizations")}
</h3>
<div className="overflow-y-auto flex-1">
<OrganizationList
selectedOrgId={selectedOrgId}
organizations={organizations}
isLoading={orgsLoading}
searchQuery={searchQuery}
onSelectOrganization={(id: string) => {
setUnits([]);
setDepartments([]);
setEmployees([]);
handleSelectOrganization(id);
}}
canCreateUnitForOrg={(id) =>
id === selectedOrgId && canCreateUnit
}
onCreateUnit={() => setAddUnitDialogOpen(true)}
/>
</div>
</div>
<div className="border rounded-md p-3 bg-white dark:bg-gray-800 shadow-sm min-w-[90%] max-w-[90%] flex-shrink-0 snap-center h-full flex flex-col dark:border-gray-700">
<h3 className="font-medium text-gray-700 dark:text-gray-200 mb-3 border-b pb-2 dark:border-gray-700 flex items-center justify-between gap-2">
<span>{t("contentManagement.Units")}</span>
<div className="flex items-center gap-2">
{unitCapacity !== null && (
<span className="text-xs font-normal px-2 py-0.5 rounded-full bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300 border border-primary-200 dark:border-primary-800">
{mergedUnits.length}/{unitCapacity}
</span>
)}
{canCreateUnit && (
<Button
size="sm"
variant="outline"
onClick={() => setAddUnitDialogOpen(true)}
className="h-7 px-2 text-xs border-primary-500 text-primary-700 hover:bg-primary-50 dark:border-primary-700 dark:text-primary-300 dark:hover:bg-primary-900/30"
>
<Plus className="h-3.5 w-3.5 mr-1" />
{t("contentManagement.createUnit", "Create Unit")}
</Button>
)}
</div>
</h3>
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500 dark:text-gray-400" />
<Input
placeholder={t("search.searchUnits", "Search units...")}
className="pl-8 text-sm dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600"
value={unitSearchQuery}
onChange={(e) => setUnitSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="overflow-y-auto flex-1">
<UnitList
organizationId={selectedOrgId}
selectedUnitId={selectedUnitId}
onSelectUnit={(id: string) => {
setDepartments([]);
setEmployees([]);
handleSelectUnit(id, mergedUnits);
}}
units={mergedUnits}
isLoading={unitsLoading}
searchQuery={unitSearchQuery}
onAddDepartment={actionHandlers.unit.onAddDepartment}
onManageUnitAdmin={actionHandlers.unit.onManageUnitAdmin}
onEditUnit={actionHandlers.unit.onEditUnit}
onArchiveUnit={actionHandlers.unit.onArchiveUnit}
onDeleteUnit={actionHandlers.unit.onDeleteUnit}
onAddFromSubCity={actionHandlers.unit.onAddFromSubCity}
/>
</div>
</div>
<div className="border rounded-md p-3 bg-white dark:bg-gray-800 shadow-sm min-w-[90%] max-w-[90%] flex-shrink-0 snap-center h-full flex flex-col dark:border-gray-700">
<h3 className="font-medium text-gray-700 dark:text-gray-200 mb-3 border-b pb-2 dark:border-gray-700">
{t("userIncoming.Departments")}
</h3>
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500 dark:text-gray-400" />
<Input
placeholder={t(
"search.searchDepartments",
"Search departments...",
)}
className="pl-8 text-sm dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600"
value={departmentSearchQuery}
onChange={(e) => setDepartmentSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="overflow-y-auto flex-1">
<DepartmentList
unitId={selectedUnitId}
selectedDepartmentId={selectedDepartmentId}
onSelectDepartment={(id: string) => {
if (id !== selectedDepartmentId) {
setEmployees([]);
handleSelectDepartment(id, departments);
}
}}
departments={departments}
positions={positions}
isLoading={deptsLoading}
searchQuery={departmentSearchQuery}
onAddUser={actionHandlers.department.onAddUserUnderDepartment}
onViewUsers={actionHandlers.department.onViewUsers}
onAddSubDepartment={
actionHandlers.department.onAddSubDepartment
}
onEditDepartment={actionHandlers.department.onEditDepartment}
onDeleteDepartment={
actionHandlers.department.onDeleteDepartment
}
onArchiveDepartment={handleArchiveDepartment}
onDelegate={actionHandlers.department.onDelegate}
onAssignUser={actionHandlers.department.onAssignUser}
onMoveDepartment={actionHandlers.department.onMoveDepartment}
/>
</div>
</div>
<div className="border rounded-md p-3 bg-white dark:bg-gray-800 shadow-sm min-w-[90%] max-w-[90%] flex-shrink-0 snap-center h-full flex flex-col dark:border-gray-700">
<h3 className="font-medium text-gray-700 dark:text-gray-200 mb-3 border-b pb-2 dark:border-gray-700">
{t("contentManagement.teamMember")}
</h3>
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500 dark:text-gray-400" />
<Input
placeholder={t(
"search.searchEmployees",
"Search employees...",
)}
className="pl-8 text-sm dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600"
value={employeeSearchQuery}
onChange={(e) => setEmployeeSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="overflow-y-auto flex-1">
<TeamMembers
departmentId={selectedDepartmentId}
departmentName={selectedDepartmentName}
employees={employees}
isLoading={employeesLoading}
searchQuery={employeeSearchQuery}
onDeleteEmployee={
actionHandlers.teamMember.onDeleteTeamMember
}
onInviteEmployee={handleInviteEmployee}
onDeactivateTeamMember={
actionHandlers.teamMember.onDeactivateTeamMember
}
/>
</div>
</div>
</div>
<button
onClick={scrollRight}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-white dark:bg-gray-800 rounded-full shadow-md p-1"
aria-label="Scroll right"
>
<ChevronRight className="h-5 w-5 dark:text-gray-300" />
</button>
</div>
</div>
{/* Desktop view - grid layout */}
<div className="hidden md:flex flex-1 p-4 flex-col lg:grid lg:grid-cols-4 lg:gap-4">
<div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="border rounded-md p-3 bg-white dark:bg-gray-800 shadow-sm dark:border-gray-700">
<h3 className="font-medium text-gray-700 dark:text-gray-200 mb-3 border-b pb-2 dark:border-gray-700">
{t("organization.organizations")}
</h3>
<OrganizationList
selectedOrgId={selectedOrgId}
organizations={organizations}
isLoading={orgsLoading}
searchQuery={searchQuery}
onSelectOrganization={(id: string) => {
setUnits([]);
setDepartments([]);
setEmployees([]);
handleSelectOrganization(id);
}}
canCreateUnitForOrg={(id) =>
id === selectedOrgId && canCreateUnit
}
onCreateUnit={() => setAddUnitDialogOpen(true)}
/>
</div>
<div className="border rounded-md p-3 bg-white dark:bg-gray-800 shadow-sm dark:border-gray-700">
<h3 className="font-medium text-gray-700 dark:text-gray-200 mb-3 border-b pb-2 dark:border-gray-700 flex items-center justify-between gap-2">
<span>{t("contentManagement.Units")}</span>
{unitCapacity !== null && (
<span className="text-xs font-normal px-2 py-0.5 rounded-full bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300 border border-primary-200 dark:border-primary-800">
{mergedUnits.length}/{unitCapacity}
</span>
)}
</h3>
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500 dark:text-gray-400" />
<Input
placeholder={t("search.searchUnits", "Search units...")}
className="pl-8 text-sm dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600"
value={unitSearchQuery}
onChange={(e) => setUnitSearchQuery(e.target.value)}
/>
</div>
</div>
<UnitList
organizationId={selectedOrgId}
selectedUnitId={selectedUnitId}
onSelectUnit={(id: string) => {
setDepartments([]);
setEmployees([]);
handleSelectUnit(id, mergedUnits);
}}
units={mergedUnits}
isLoading={unitsLoading}
searchQuery={unitSearchQuery}
onAddDepartment={actionHandlers.unit.onAddDepartment}
onManageUnitAdmin={actionHandlers.unit.onManageUnitAdmin}
onEditUnit={actionHandlers.unit.onEditUnit}
onArchiveUnit={actionHandlers.unit.onArchiveUnit}
onDeleteUnit={actionHandlers.unit.onDeleteUnit}
onAddFromSubCity={actionHandlers.unit.onAddFromSubCity}
/>
</div>
<div className="border rounded-md p-3 bg-white dark:bg-gray-800 shadow-sm dark:border-gray-700">
<h3 className="font-medium text-gray-700 dark:text-gray-200 mb-3 border-b pb-2 dark:border-gray-700">
{t("userIncoming.Departments")}
</h3>
<div className="mb-3">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500 dark:text-gray-400" />
<Input
placeholder={t(
"search.searchDepartments",
"Search departments...",
)}
className="pl-8 text-sm dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600"
value={departmentSearchQuery}
onChange={(e) => setDepartmentSearchQuery(e.target.value)}
/>
</div>
</div>
<DepartmentList
unitId={selectedUnitId}
selectedDepartmentId={selectedDepartmentId}
onSelectDepartment={(id: string) => {
if (id !== selectedDepartmentId) {
setEmployees([]);
handleSelectDepartment(id, departments);
}
}}
departments={departments}
positions={positions}
isLoading={deptsLoading}
searchQuery={departmentSearchQuery}
onAddUser={actionHandlers.department.onAddUserUnderDepartment}
onViewUsers={actionHandlers.department.onViewUsers}
onAddSubDepartment={actionHandlers.department.onAddSubDepartment}
onEditDepartment={actionHandlers.department.onEditDepartment}
onDeleteDepartment={actionHandlers.department.onDeleteDepartment}
onArchiveDepartment={handleArchiveDepartment}
onDelegate={actionHandlers.department.onDelegate}
onAssignUser={actionHandlers.department.onAssignUser}
onMoveDepartment={actionHandlers.department.onMoveDepartment}
/>
</div>
</div>
<div className="mt-4 lg:mt-0 border rounded-md p-4 bg-white dark:bg-gray-800 shadow-sm dark:border-gray-700">
<h3 className="font-medium text-gray-700 dark:text-gray-200 mb-4 border-b pb-2 flex items-center dark:border-gray-700">
<UserCircle className="mr-2 h-4 w-4 text-gray-500 dark:text-gray-400" />
{t("contentManagement.teamMember")}
</h3>
<div className="mb-4">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500 dark:text-gray-400" />
<Input
placeholder={t("search.searchEmployees", "Search employees...")}
className="pl-8 text-sm dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600"
value={employeeSearchQuery}
onChange={(e) => setEmployeeSearchQuery(e.target.value)}
/>
</div>
</div>
<TeamMembers
departmentId={selectedDepartmentId}
departmentName={selectedDepartmentName}
employees={employees}
isLoading={employeesLoading}
searchQuery={employeeSearchQuery}
onDeleteEmployee={actionHandlers.teamMember.onDeleteTeamMember}
onInviteEmployee={handleInviteEmployee}
onDeactivateTeamMember={
actionHandlers.teamMember.onDeactivateTeamMember
}
/>
</div>
</div>
{/* Dialogs */}
{actionHandlers.dialogs.manageUnitAdmin.isOpen && (
<ManageUnitAdminDialog {...actionHandlers.dialogs.manageUnitAdmin} />
)}
{actionHandlers.dialogs.userUnderDepartment.isOpen && (
<AddUserUnderDepartmentDialog
{...actionHandlers.dialogs.userUnderDepartment}
/>
)}
{actionHandlers.dialogs.editUnit.isOpen && (
<EditUnitDialog
{...actionHandlers.dialogs.editUnit}
units={fetchedUnits}
/>
)}
{actionHandlers.dialogs.deleteUnit.isOpen && (
<DeleteUnitDialog {...actionHandlers.dialogs.deleteUnit} />
)}
{actionHandlers.dialogs.permanentDeleteUnit.isOpen && (
<PermanentDeleteUnitDialog
{...actionHandlers.dialogs.permanentDeleteUnit}
/>
)}
{actionHandlers.dialogs.addFromSubCity.isOpen && (
<AddFromSubCityDialog {...actionHandlers.dialogs.addFromSubCity} />
)}
{addUnitDialogOpen && (
<AddUnitDialog
isOpen={addUnitDialogOpen}
onClose={() => setAddUnitDialogOpen(false)}
organizationId={selectedOrgId}
/>
)}
{actionHandlers.dialogs.department.isOpen && (
<AddDepartmentDialog
{...actionHandlers.dialogs.department}
organizationId={selectedOrgId}
/>
)}
{actionHandlers.dialogs.editDepartment.isOpen && (
<EditDepartmentDialog
{...actionHandlers.dialogs.editDepartment}
postions={positions}
/>
)}
{actionHandlers.dialogs.deleteDepartment.isOpen && (
<DeleteDepartmentDialog {...actionHandlers.dialogs.deleteDepartment} />
)}
{actionHandlers.dialogs.subDepartment.isOpen && (
<AddSubDepartmentDialog {...actionHandlers.dialogs.subDepartment} />
)}
{actionHandlers.dialogs.moveDepartment.isOpen && (
<MoveDepartmentDialog
isOpen={actionHandlers.dialogs.moveDepartment.isOpen}
onClose={actionHandlers.dialogs.moveDepartment.onClose}
departmentId={actionHandlers.dialogs.moveDepartment.departmentId}
organizationId={selectedOrgId}
unitId={selectedUnitId}
/>
)}
{actionHandlers.dialogs.user.isOpen && (
<AddUserDialog {...actionHandlers.dialogs.user} />
)}
{actionHandlers.dialogs.viewUsers.isOpen && (
<ViewUsersDialog {...actionHandlers.dialogs.viewUsers} />
)}
{actionHandlers.dialogs.assignUser.isOpen && (
<AddAssignUserDialog {...actionHandlers.dialogs.assignUser} />
)}
{actionHandlers.dialogs.deleteTeamMember.isOpen && (
<DeleteTeamMemberDialog {...actionHandlers.dialogs.deleteTeamMember} />
)}
{actionHandlers.dialogs.deactivateTeamMember.isOpen && (
<DeactivateTeamMemberDialog
isOpen={actionHandlers.dialogs.deactivateTeamMember.isOpen}
onClose={actionHandlers.dialogs.deactivateTeamMember.onClose}
userId={actionHandlers.dialogs.deactivateTeamMember.userId}
teamMemberName={
actionHandlers.dialogs.deactivateTeamMember.teamMemberName
}
/>
)}
{/* Unit Selection Modal */}
{showUnitSelection && (
<div className="fixed inset-0 bg-black/40 dark:bg-black/60 flex items-center justify-center z-50">
<UnitSelectionModal
units={exportUnits}
isLoading={isLoadingUnits}
onSelectUnit={handleUnitSelect}
onClose={() => setShowUnitSelection(false)}
/>
</div>
)}
{/* Confirm Resend to All Modal */}
{showConfirmAllModal && (
<div className="fixed inset-0 bg-black/40 dark:bg-black/60 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 shadow-xl border dark:border-gray-700 animate-in fade-in zoom-in-95 duration-150">
<h3 className="text-lg font-semibold mb-2 dark:text-white">
{t(
"userManagement.confirmResendTitle",
"Resend Verification Code",
)}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
{t(
"userManagement.confirmResendDescription",
"Are you sure you want to resend verification to all pending users?",
)}
</p>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setShowConfirmAllModal(false)}
disabled={isSendingInvitations}
className="dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
{t("common.cancel")}
</Button>
<Button
onClick={handleResendToAll}
disabled={isSendingInvitations}
className="bg-primary hover:bg-primary-700 text-white font-medium shadow-md"
>
{isSendingInvitations ? (
<span className="flex items-center">
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
{t("common.sending", "Sending...")}
</span>
) : (
t("common.send", "Send")
)}
</Button>
</div>
</div>
</div>
)}
{/* Resend Invitation (MultiSelect) Modal */}
{showResendModal && (
<div className="fixed inset-0 bg-black/40 dark:bg-black/60 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 flex flex-col max-h-[85vh] shadow-xl border dark:border-gray-700 animate-in fade-in zoom-in-95 duration-150">
<h3 className="text-lg font-semibold mb-2 dark:text-white">
{t("userManagement.resendInvitation", "Resend Invitation")}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
{t(
"userManagement.selectUsersDescription",
"Select the users you want to resend invitations to:",
)}
</p>
{/* Scrollable container for the dropdown selection and items */}
<div className="flex-1 overflow-y-auto my-2 pr-1 space-y-4 min-h-[200px]">
{isLoadingPending ? (
<div className="flex justify-center items-center py-12">
<RefreshCw className="animate-spin h-8 w-8 text-primary" />
</div>
) : pendingUsers.length === 0 ? (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
{t(
"userManagement.noPendingUsers",
"No pending users found in your unit.",
)}
</div>
) : (
<div className="w-full">
<MultiSelect
options={pendingUsers.map((emp) => {
const empName =
typeof emp.name === "string"
? emp.name
: localizedName(emp.name) ||
(emp.user?.name
? localizedName(emp.user.name)
: "Unnamed");
return {
label: empName,
value: emp.id,
};
})}
value={selectedUserIds}
onValueChange={setSelectedUserIds}
placeholder={t(
"userManagement.selectUsersPlaceholder",
"Select employees...",
)}
maxCount={5}
/>
</div>
)}
</div>
<div className="flex justify-end gap-2 mt-6 border-t pt-4 dark:border-gray-700">
<Button
variant="outline"
onClick={() => {
setShowResendModal(false);
setSelectedUserIds([]);
}}
disabled={isSendingInvitations}
className="dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
{t("common.cancel")}
</Button>
<Button
onClick={handleSendSelectedInvitations}
disabled={selectedUserIds.length === 0 || isSendingInvitations}
className="bg-primary hover:bg-primary-700 text-white font-medium shadow-md"
>
{isSendingInvitations ? (
<span className="flex items-center">
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
{t("common.sending", "Sending...")}
</span>
) : (
t("common.send", "Send")
)}
</Button>
</div>
</div>
</div>
)}
{/* Organization Employee Search Modal */}
<OrganizationEmployeeSearch
organizationId={organizationId}
isOpen={showEmployeeSearch}
onClose={() => setShowEmployeeSearch(false)}
/>
{showSendToUnitModal && (
<div className="fixed inset-0 bg-black/40 dark:bg-black/60 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4 flex flex-col max-h-[85vh] shadow-xl border dark:border-gray-700 animate-in fade-in zoom-in-95 duration-150">
<h3 className="text-lg font-semibold mb-2 dark:text-white">
{t("userManagement.sendToSelectedUnit", "Send to Selected Unit")}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Select the unit you want to send verification to:
</p>
<div className="flex-1 overflow-y-auto my-2 pr-1 space-y-4 min-h-[200px]">
<SingleSelect
options={mergedUnits.map((unit) => {
const unitName =
typeof unit.name === "string"
? unit.name
: localizedName(unit.name) ||
(typeof unit.name === "object" && unit.name?.en) ||
unit.id ||
"Unnamed Unit";
return {
label: unitName,
value: unit.id,
};
})}
value={selectedSendUnitId}
onValueChange={setSelectedSendUnitId}
placeholder={t(
"search.selectUnitPlaceholder",
"Select unit...",
)}
/>
</div>
<div className="flex justify-end gap-2 mt-6 border-t pt-4 dark:border-gray-700">
<Button
variant="outline"
onClick={() => {
setShowSendToUnitModal(false);
setSelectedSendUnitId("");
}}
className="dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
{t("common.cancel")}
</Button>
<Button
onClick={handleSendToSelectedUnits}
disabled={!selectedSendUnitId}
className="bg-primary hover:bg-primary-700 text-white font-medium shadow-md"
>
{t("common.send", "Send")}
</Button>
</div>
</div>
</div>
)}
</div>
);
};
export default UserManagementTree;