From cbff529e31d5592a391f3d9963c6fd3b51dab615 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 23 Jul 2026 07:40:48 +0000 Subject: [PATCH 1/2] feat: rebuild the admin pages --- .../dto/create-organization-user.dto.ts | 6 +- .../src/locales/am/translation.json | 87 ++++- .../src/locales/en/translation.json | 82 ++++ .../backoffice/src/pages/DashboardPage.tsx | 6 +- .../src/pages/OrganizationAdminsPage.tsx | 4 +- .../components/org-admins/AdminFormModal.tsx | 211 ++++++++++ .../org-admins/AssignExistingAdminModal.tsx | 165 ++++++++ .../org-admins/OrgAdminsColumnDefn.tsx | 228 +++++++++++ .../components/org-admins/OrgAdminsPage.tsx | 364 ++++++++++++++++++ .../components/org-admins/OrgPicker.tsx | 142 +++++++ .../AssignOrgAdminDialog.tsx | 277 ------------- .../organizationAdmins/OrganizationAdmins.tsx | 134 ------- .../OrganizationAdminsActions.tsx | 80 ---- .../OrganizationAdminsColumnDefn.tsx | 77 ---- .../src/super-admin/hooks/useOrgAdmins.ts | 199 ++++++++++ .../CreatePositionForm.tsx | 2 +- 16 files changed, 1485 insertions(+), 579 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AdminFormModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx create mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/AssignOrgAdminDialog.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdmins.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsActions.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsColumnDefn.tsx create mode 100644 apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts diff --git a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts index cf324a501..cc7507dd6 100644 --- a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts +++ b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator"; +import { Type } from "class-transformer"; +import { IsBoolean, IsEmail, IsOptional, IsString, MinLength, ValidateNested } from "class-validator"; class CreateOrganizationUserNameDto { @ApiProperty() @@ -29,7 +30,8 @@ export class CreateOrganizationUserDto { phoneNumber?: string; @ApiProperty({ type: CreateOrganizationUserNameDto }) - @IsObject() + @ValidateNested() + @Type(() => CreateOrganizationUserNameDto) name!: CreateOrganizationUserNameDto; @ApiProperty({ required: false, default: false }) diff --git a/apps/edr-freight-web/backoffice/src/locales/am/translation.json b/apps/edr-freight-web/backoffice/src/locales/am/translation.json index a37525807..4f3daeaee 100644 --- a/apps/edr-freight-web/backoffice/src/locales/am/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/am/translation.json @@ -2585,7 +2585,6 @@ "archiveDepartment": "የስራ መደብ መጠርያ አርክብ አድርግ", "deleteDepartment": "የስራ መደብ መጠርያ ሰርዝ", "deleteConfirm": "የስራ መደብ መጠርያ ይሰርዝ?", - "delete": "ሰርዝ", "deleteFailed": "የስራ መደብ መጠርያ ሰረዝ ወደ ተሳክቶ", "cannotDeleteWithEmployees": "ተመድቦ ካለበት ሰራተኞች ጋር የስራ መደብ መጠርያ መሰረዝ አይቻልም", "reassignEmployeesFirst": "እባክዎ ሁሉንም ሰራተኞች በዚህ ክፍል ውስጥ ዳግም ይሰጧቸው ወይም ያስወግዱ።", @@ -3117,7 +3116,7 @@ "referenceNumberStyles": "የማጣቀሻ ቁጥር ስታይሎች", "other": "ሌላ", "styleCategories": "የስታይል ምድቦች", - "styleEditor": "ስታይል አርታኢ", + "styleEditor": "የስታይል አርታዒ", "livePreview": "የቀጥታ ቅድመ-እይታ", "fontsHint": "አማራጭ የቅርጸ-ቁምፊ ሪሶርስ ይምረጡ።", "fontResource": "የቅርጸ-ቁምፊ ፋይል", @@ -3209,11 +3208,9 @@ "previewLanguage": "የቅድመ-እይታ ቋንቋ", "amharic": "አማርኛ", "english": "እንግሊዝኛ", - "styleCategories": "የስታይል ምድቦች", "categoryHint": "ለማርትዕ ክፍል ይምረጡ", "expandSidebar": "የጎን ማውጫን ዘርጋ", "collapseSidebar": "የጎን ማውጫን ጠቅልል", - "styleEditor": "የስታይል አርታዒ", "headerFooterSelection": "ራስጌ እና ግርጌ", "noSettings": "ምንም የስታይል ቅንብሮች የሉም", "visible": "የሚታይ", @@ -7393,5 +7390,87 @@ "department": "ዲፓርትመንት", "unit": "ክፍል", "notAvailable": "ያልዋቀረ" + }, + "orgAdmins": { + "title": "የድርጅት አስተዳዳሪዎች", + "subtitle": "አስተዳዳሪዎቹን ለማየት እና ለማስተዳደር ድርጅት ይምረጡ።", + "tableName": "የድርጅት አስተዳዳሪዎች", + "selectOrg": "ድርጅት ይምረጡ", + "searchOrgs": "ድርጅቶችን ይፈልጉ...", + "noOrgsFound": "ምንም ድርጅት አልተገኘም።", + "adminsCount": "{{count}} አስተዳዳሪ", + "adminsCount_other": "{{count}} አስተዳዳሪዎች", + "noAdmins": "አስተዳዳሪ የለም", + "activeEmployees": "{{count}} ንቁ ሰራተኞች", + "selectOrgPrompt": "አስተዳዳሪዎቹን ለማስተዳደር ድርጅት ይምረጡ", + "selectOrgPromptHint": "ከላይ ያለውን መምረጫ ተጠቅመው ድርጅት ይፈልጉ እና ይምረጡ።", + "noAdminsHint": "{{name}} እስካሁን አስተዳዳሪ የለውም። አዲስ አስተዳዳሪ ይጋብዙ ወይም ነባር ሰራተኛ ይመድቡ።", + "inviteAdmin": "አስተዳዳሪ ይጋብዙ", + "assignExisting": "ነባር ሰራተኛ ይመድቡ", + "roleOrgAdmin": "የድርጅት አስተዳዳሪ", + "roleUnitAdmin": "የክፍል አስተዳዳሪ", + "statusInvited": "የተጋበዘ", + "statusActive": "ንቁ", + "statusInactive": "ንቁ ያልሆነ", + "columns": { + "name": "ስም", + "email": "ኢሜይል", + "phone": "ስልክ", + "role": "ሚና", + "status": "ሁኔታ", + "addedOn": "የተጨመረበት ቀን", + "actions": "እርምጃዎች" + }, + "actions": { + "edit": "መገለጫ ያስተካክሉ", + "resend": "ግብዣ እንደገና ይላኩ", + "activate": "መለያ ያንቁ", + "deactivate": "መለያ ያቦዝኑ", + "remove": "አስተዳዳሪ ያስወግዱ" + }, + "form": { + "nameEn": "ስም (እንግሊዝኛ)", + "nameAm": "ስም (አማርኛ)", + "username": "የተጠቃሚ ስም", + "email": "ኢሜይል", + "phoneNumber": "ስልክ ቁጥር" + }, + "invite": { + "title": "አዲስ አስተዳዳሪ ይጋብዙ", + "description": "ተጋባዡ የይለፍ ቃል ለማዘጋጀት እና መለያውን ለማንቃት አገናኝ በSMS ይደርሰዋል።", + "submit": "ግብዣ ይላኩ" + }, + "edit": { + "title": "የአስተዳዳሪ መገለጫ ያስተካክሉ", + "description": "የዚህን አስተዳዳሪ የመገለጫ ዝርዝሮች ያዘምኑ።", + "submit": "ለውጦችን ያስቀምጡ" + }, + "assign": { + "title": "ነባር ሰራተኛ ይመድቡ", + "description": "የዚህን ድርጅት ሰራተኛ ወደ አስተዳዳሪነት ያሳድጉ።", + "searchUsers": "ሰራተኞችን በስም ወይም በኢሜይል ይፈልጉ...", + "noUsersFound": "ምንም ሰራተኛ አልተገኘም።", + "alreadyAdmin": "አስቀድሞ አስተዳዳሪ ነው", + "submit": "እንደ አስተዳዳሪ ይመድቡ" + }, + "confirmRemove": { + "title": "አስተዳዳሪ ይወገድ?", + "description": "ይህ የ{{name}}ን የአስተዳዳሪነት ሚና ከ{{org}} ያስወግዳል። የተጠቃሚው መለያ ራሱ ይቀራል።", + "removing": "በማስወገድ ላይ..." + }, + "confirmToggle": { + "activateTitle": "መለያ ይንቃ?", + "deactivateTitle": "መለያ ይቦዝን?", + "description": "ይህ የ{{name}}ን የመለያ ሁኔታ በመላው ስርዓቱ ላይ ይቀይራል፣ ለዚህ ድርጅት ብቻ አይደለም።" + }, + "toasts": { + "invited": "ግብዣው በተሳካ ሁኔታ ተልኳል!", + "assigned": "አስተዳዳሪ በተሳካ ሁኔታ ተመድቧል!", + "removed": "አስተዳዳሪ በተሳካ ሁኔታ ተወግዷል!", + "resent": "ግብዣው እንደገና ተልኳል!", + "activated": "መለያው ነቅቷል!", + "deactivated": "መለያው ቦዝኗል!", + "profileUpdated": "መገለጫው ተዘምኗል!" + } } } diff --git a/apps/edr-freight-web/backoffice/src/locales/en/translation.json b/apps/edr-freight-web/backoffice/src/locales/en/translation.json index 8cf8582b7..c300cb27a 100644 --- a/apps/edr-freight-web/backoffice/src/locales/en/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/en/translation.json @@ -7391,5 +7391,87 @@ "department": "Department", "unit": "Unit", "notAvailable": "Not Available" + }, + "orgAdmins": { + "title": "Organization Admins", + "subtitle": "Pick an organization to view and manage its administrators.", + "tableName": "Organization Admins", + "selectOrg": "Select an organization", + "searchOrgs": "Search organizations...", + "noOrgsFound": "No organizations found.", + "adminsCount": "{{count}} admin", + "adminsCount_other": "{{count}} admins", + "noAdmins": "No admins", + "activeEmployees": "{{count}} active employees", + "selectOrgPrompt": "Select an organization to manage its admins", + "selectOrgPromptHint": "Use the selector above to search and pick an organization.", + "noAdminsHint": "{{name}} has no administrators yet. Invite a new admin or assign an existing employee.", + "inviteAdmin": "Invite Admin", + "assignExisting": "Assign Existing", + "roleOrgAdmin": "Org Admin", + "roleUnitAdmin": "Unit Admin", + "statusInvited": "Invited", + "statusActive": "Active", + "statusInactive": "Inactive", + "columns": { + "name": "Name", + "email": "Email", + "phone": "Phone", + "role": "Role", + "status": "Status", + "addedOn": "Added On", + "actions": "Actions" + }, + "actions": { + "edit": "Edit Profile", + "resend": "Resend Invite", + "activate": "Activate Account", + "deactivate": "Deactivate Account", + "remove": "Remove Admin" + }, + "form": { + "nameEn": "Name (English)", + "nameAm": "Name (Amharic)", + "username": "Username", + "email": "Email", + "phoneNumber": "Phone Number" + }, + "invite": { + "title": "Invite New Admin", + "description": "The invitee receives an SMS with a link to set their password and activate the account.", + "submit": "Send Invite" + }, + "edit": { + "title": "Edit Admin Profile", + "description": "Update this administrator's profile details.", + "submit": "Save Changes" + }, + "assign": { + "title": "Assign Existing Employee", + "description": "Promote an employee of this organization to administrator.", + "searchUsers": "Search employees by name or email...", + "noUsersFound": "No employees found.", + "alreadyAdmin": "Already admin", + "submit": "Assign as Admin" + }, + "confirmRemove": { + "title": "Remove Admin?", + "description": "This removes the admin role of {{name}} for {{org}}. The user account itself is kept.", + "removing": "Removing..." + }, + "confirmToggle": { + "activateTitle": "Activate Account?", + "deactivateTitle": "Deactivate Account?", + "description": "This changes the account status of {{name}} across the whole platform, not just for this organization." + }, + "toasts": { + "invited": "Invitation sent successfully!", + "assigned": "Admin assigned successfully!", + "removed": "Admin removed successfully!", + "resent": "Invitation re-sent successfully!", + "activated": "Account activated successfully!", + "deactivated": "Account deactivated successfully!", + "profileUpdated": "Profile updated successfully!" + } } } diff --git a/apps/edr-freight-web/backoffice/src/pages/DashboardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/DashboardPage.tsx index 701400310..2df952af8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/DashboardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/DashboardPage.tsx @@ -67,7 +67,8 @@ const DashboardPage = () => { refetch(); refetchAdmins(); }} - className="bg-primary hover:bg-primary/90 text-primary-foreground"> + className="bg-primary hover:bg-primary/90 text-primary-foreground" + > {t("organization.retry")} @@ -140,7 +141,8 @@ const DashboardPage = () => { diff --git a/apps/edr-freight-web/backoffice/src/pages/OrganizationAdminsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/OrganizationAdminsPage.tsx index 6fce0ec39..0503baad8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/OrganizationAdminsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/OrganizationAdminsPage.tsx @@ -1,7 +1,7 @@ -import OrganizationsAdmins from "@/super-admin/components/organizationAdmins/OrganizationAdmins"; +import OrgAdminsPage from "@/super-admin/components/org-admins/OrgAdminsPage"; const OrganizationAdminsPage = () => { - return ; + return ; }; export default OrganizationAdminsPage; diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AdminFormModal.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AdminFormModal.tsx new file mode 100644 index 000000000..8fff2764d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AdminFormModal.tsx @@ -0,0 +1,211 @@ +import { useEffect } from "react"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { t } from "i18next"; +import { useTranslation } from "react-i18next"; +import { Loader2 } from "lucide-react"; +import { Button } from "@/shared/common/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/common/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/shared/common/ui/form"; +import { Input } from "@/shared/common/ui/input"; +import { OrgAdminUser } from "@/super-admin/hooks/useOrgAdmins"; + +const adminSchema = z.object({ + name: z.object({ + en: z.string().min(1, t("organization.englishNameRequired")), + am: z.string().min(1, t("organization.amharicNameRequired")), + }), + username: z.string().min(3, t("organization.usernameMinLength")), + email: z.string().email(t("organization.invalidEmail")), + phoneNumber: z + .string() + .regex(/^(\+251|0)?9\d{8}$/, t("organization.invalidPhoneNumber")), +}); + +export type AdminFormValues = z.infer; + +const EMPTY_VALUES: AdminFormValues = { + name: { en: "", am: "" }, + username: "", + email: "", + phoneNumber: "", +}; + +interface AdminFormModalProps { + isOpen: boolean; + onClose: () => void; + /** null → invite a new admin; set → edit this admin's profile */ + admin: OrgAdminUser | null; + onSubmit: (values: AdminFormValues) => void; + isSubmitting: boolean; +} + +/** Invite-new-admin / edit-admin-profile modal (same fields, one form). */ +export default function AdminFormModal({ + isOpen, + onClose, + admin, + onSubmit, + isSubmitting, +}: AdminFormModalProps) { + const { t } = useTranslation(); + const isEdit = !!admin; + + const form = useForm({ + resolver: zodResolver(adminSchema), + defaultValues: EMPTY_VALUES, + }); + + useEffect(() => { + if (isOpen) { + form.reset( + admin + ? { + name: { + en: admin.name?.en ?? "", + am: admin.name?.am ?? "", + }, + username: admin.username ?? "", + email: admin.email ?? "", + phoneNumber: admin.phoneNumber ?? "", + } + : EMPTY_VALUES, + ); + } + }, [isOpen, admin, form]); + + const handleClose = () => { + if (isSubmitting) return; + onClose(); + }; + + return ( + !open && handleClose()}> + + + + {isEdit ? t("orgAdmins.edit.title") : t("orgAdmins.invite.title")} + + + {isEdit + ? t("orgAdmins.edit.description") + : t("orgAdmins.invite.description")} + + + +
+ +
+ ( + + {t("orgAdmins.form.nameEn")} + + + + + + )} + /> + ( + + {t("orgAdmins.form.nameAm")} + + + + + + )} + /> +
+ ( + + {t("orgAdmins.form.username")} + + + + + + )} + /> + ( + + {t("orgAdmins.form.email")} + + + + + + )} + /> + ( + + {t("orgAdmins.form.phoneNumber")} + + + + + + )} + /> + + + + + + + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx new file mode 100644 index 000000000..0390f2671 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx @@ -0,0 +1,165 @@ +import { useMemo, useState } from "react"; +import { Check, Loader2, UserPlus } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/shared/common/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/common/ui/dialog"; +import { Input } from "@/shared/common/ui/input"; +import { ScrollArea } from "@/shared/common/ui/scroll-area"; +import { Badge } from "@/shared/common/ui/badge"; +import { cn } from "@/super-admin/lib/utils"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { useEmployees } from "@/user-management/hooks/useEmployees"; + +interface AssignExistingAdminModalProps { + isOpen: boolean; + onClose: () => void; + organizationId: string; + /** user ids that are already admins — shown disabled */ + existingAdminIds: string[]; + onAssign: (userId: string) => void; + isAssigning: boolean; +} + +/** Promote an existing employee of the selected organization to org admin. */ +export default function AssignExistingAdminModal({ + isOpen, + onClose, + organizationId, + existingAdminIds, + onAssign, + isAssigning, +}: AssignExistingAdminModalProps) { + const { t } = useTranslation(); + const localizedName = useLocalizedName(); + const [selectedUserId, setSelectedUserId] = useState(""); + const [search, setSearch] = useState(""); + + const { employeesResponseByOrg, isLoadingEmployeesByOrg } = useEmployees({ + organizationId: isOpen ? organizationId : undefined, + params: { take: 3000, skip: 0 }, + }); + + const filteredEmployees = useMemo(() => { + const query = search.trim().toLowerCase(); + const employees = employeesResponseByOrg?.items ?? []; + if (!query) return employees; + return employees.filter((employee: any) => { + const name = localizedName(employee.user?.name).toLowerCase(); + const email = employee.user?.email?.toLowerCase() ?? ""; + return name.includes(query) || email.includes(query); + }); + }, [employeesResponseByOrg, localizedName, search]); + + const handleClose = () => { + if (isAssigning) return; + setSelectedUserId(""); + setSearch(""); + onClose(); + }; + + return ( + !open && handleClose()}> + + + {t("orgAdmins.assign.title")} + + {t("orgAdmins.assign.description")} + + + +
+ setSearch(event.target.value)} + placeholder={t("orgAdmins.assign.searchUsers")} + /> + {isLoadingEmployeesByOrg ? ( +
+ +
+ ) : ( + +
+ {filteredEmployees.map((employee: any) => { + const userId = employee.user?.id; + if (!userId) return null; + const isAlreadyAdmin = existingAdminIds.includes(userId); + return ( + + ); + })} + {filteredEmployees.length === 0 && ( +

+ {t("orgAdmins.assign.noUsersFound")} +

+ )} +
+
+ )} +
+ + + + + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx new file mode 100644 index 000000000..97c3bfbc6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx @@ -0,0 +1,228 @@ +import { ColumnDef } from "@tanstack/react-table"; +import { + ArrowUpDown, + MoreHorizontal, + Pencil, + Send, + Trash2, + UserCheck, + UserX, +} from "lucide-react"; +import { t } from "i18next"; +import { Button } from "@/shared/common/ui/button"; +import { Badge } from "@/shared/common/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/common/ui/dropdown-menu"; +import { OrgAdminUser } from "@/super-admin/hooks/useOrgAdmins"; + +export const ORG_ADMIN_ROLE_KEY = "organization_admin"; +export const UNIT_ADMIN_ROLE_KEY = "unit_admin"; + +export interface AdminRoleInfo { + isOrgAdmin: boolean; + isUnitAdmin: boolean; + unitId?: string; +} + +/** + * all-admins/:id returns users who are org admins of the org OR unit admins of + * one of its units; userRoles carries every role of the user, so match the org + * explicitly for the org-admin grant. + */ +// ponytail: unit relation isn't loaded, so a unit_admin grant from another org +// can't be told apart — acceptable, the server only returns admins of this org. +export function getAdminRoleInfo( + admin: OrgAdminUser, + selectedOrgId: string, +): AdminRoleInfo { + const roles = admin.userRoles ?? []; + const isOrgAdmin = roles.some( + (r) => + r.role?.key === ORG_ADMIN_ROLE_KEY && + r.organizationId === selectedOrgId, + ); + const unitRole = roles.find( + (r) => r.role?.key === UNIT_ADMIN_ROLE_KEY && r.unitId, + ); + return { + isOrgAdmin, + isUnitAdmin: !!unitRole, + unitId: unitRole?.unitId ?? undefined, + }; +} + +interface ColumnCallbacks { + selectedOrgId: string; + localizedName: (name?: { am?: string; en?: string }) => string; + onEdit: (admin: OrgAdminUser) => void; + onResend: (admin: OrgAdminUser) => void; + onToggleActive: (admin: OrgAdminUser) => void; + onRemove: (admin: OrgAdminUser, roleInfo: AdminRoleInfo) => void; +} + +export function getOrgAdminsColumnDefn({ + selectedOrgId, + localizedName, + onEdit, + onResend, + onToggleActive, + onRemove, +}: ColumnCallbacks): ColumnDef[] { + return [ + { + id: "name", + accessorFn: (row) => localizedName(row.name), + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+

+ {localizedName(row.original.name) || "—"} +

+

+ {row.original.username} +

+
+ ), + }, + { + id: "email", + accessorFn: (row) => row.email ?? "", + header: () => t("orgAdmins.columns.email"), + cell: ({ row }) => ( + {row.original.email || "—"} + ), + }, + { + id: "phoneNumber", + accessorFn: (row) => row.phoneNumber ?? "", + header: () => t("orgAdmins.columns.phone"), + cell: ({ row }) => ( + {row.original.phoneNumber || "—"} + ), + }, + { + id: "role", + header: () => t("orgAdmins.columns.role"), + cell: ({ row }) => { + const info = getAdminRoleInfo(row.original, selectedOrgId); + return ( +
+ {info.isOrgAdmin && ( + + {t("orgAdmins.roleOrgAdmin")} + + )} + {info.isUnitAdmin && ( + + {t("orgAdmins.roleUnitAdmin")} + + )} +
+ ); + }, + }, + { + id: "status", + accessorFn: (row) => + !row.hasSetPassword + ? "invited" + : row.isActive + ? "active" + : "inactive", + header: () => t("orgAdmins.columns.status"), + cell: ({ row }) => { + const admin = row.original; + if (!admin.hasSetPassword) { + return ( + + {t("orgAdmins.statusInvited")} + + ); + } + return admin.isActive ? ( + + {t("orgAdmins.statusActive")} + + ) : ( + + {t("orgAdmins.statusInactive")} + + ); + }, + }, + { + id: "createdAt", + accessorFn: (row) => row.createdAt ?? "", + header: () => t("orgAdmins.columns.addedOn"), + cell: ({ row }) => + row.original.createdAt + ? new Date(row.original.createdAt).toLocaleDateString() + : "—", + }, + { + id: "actions", + header: () => t("orgAdmins.columns.actions"), + enableHiding: false, + cell: ({ row }) => { + const admin = row.original; + const roleInfo = getAdminRoleInfo(admin, selectedOrgId); + return ( + + + + + + onEdit(admin)}> + + {t("orgAdmins.actions.edit")} + + {!admin.hasSetPassword && ( + onResend(admin)}> + + {t("orgAdmins.actions.resend")} + + )} + onToggleActive(admin)}> + {admin.isActive ? ( + <> + + {t("orgAdmins.actions.deactivate")} + + ) : ( + <> + + {t("orgAdmins.actions.activate")} + + )} + + + onRemove(admin, roleInfo)} + > + + {t("orgAdmins.actions.remove")} + + + + ); + }, + }, + ]; +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx new file mode 100644 index 000000000..1935d049c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx @@ -0,0 +1,364 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Building2, Plus, UserPlus, Users2 } from "lucide-react"; +import { Button } from "@/shared/common/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/common/ui/alert-dialog"; +import { Badge } from "@/shared/common/ui/badge"; +import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; +import { + OrgAdminUser, + useOrgAdmins, +} from "@/super-admin/hooks/useOrgAdmins"; +import { OrgPicker } from "./OrgPicker"; +import { + AdminRoleInfo, + getOrgAdminsColumnDefn, +} from "./OrgAdminsColumnDefn"; +import AdminFormModal, { AdminFormValues } from "./AdminFormModal"; +import AssignExistingAdminModal from "./AssignExistingAdminModal"; + +interface RemoveTarget { + admin: OrgAdminUser; + roleInfo: AdminRoleInfo; +} + +export default function OrgAdminsPage() { + const { t } = useTranslation(); + const localizedName = useLocalizedName(); + + const [selectedOrg, setSelectedOrg] = useState(null); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + // modals & confirms + const [formOpen, setFormOpen] = useState(false); + const [editAdmin, setEditAdmin] = useState(null); + const [assignOpen, setAssignOpen] = useState(false); + const [removeTarget, setRemoveTarget] = useState(null); + const [toggleTarget, setToggleTarget] = useState(null); + + const { + adminsResponse, + isLoading, + refetch, + inviteAdmin, + isInviting, + assignAdmin, + isAssigning, + removeAdmin, + isRemoving, + resendInvite, + toggleActive, + isToggling, + updateAdminProfile, + isUpdatingProfile, + } = useOrgAdmins(selectedOrg?.id, { + take: pageSize, + skip: pageIndex * pageSize, + }); + + useEffect(() => { + setPageIndex(0); + }, [selectedOrg?.id, pageSize]); + + const admins = adminsResponse?.items ?? []; + const adminCount = adminsResponse?.count ?? 0; + const existingAdminIds = useMemo( + () => admins.map((admin) => admin.id), + [admins], + ); + + const handleFormSubmit = (values: AdminFormValues) => { + if (!selectedOrg) return; + if (editAdmin) { + updateAdminProfile( + { id: editAdmin.id, payload: values }, + { + onSuccess: () => { + setFormOpen(false); + setEditAdmin(null); + }, + }, + ); + } else { + inviteAdmin( + { organizationId: selectedOrg.id, ...values }, + { onSuccess: () => setFormOpen(false) }, + ); + } + }; + + const handleAssign = (userId: string) => { + if (!selectedOrg) return; + assignAdmin( + { organizationId: selectedOrg.id, userId }, + { onSuccess: () => setAssignOpen(false) }, + ); + }; + + const handleRemoveConfirm = () => { + if (!removeTarget || !selectedOrg) return; + const { admin, roleInfo } = removeTarget; + removeAdmin( + roleInfo.isOrgAdmin + ? { userId: admin.id, organizationId: selectedOrg.id } + : { userId: admin.id, unitId: roleInfo.unitId }, + { onSuccess: () => setRemoveTarget(null) }, + ); + }; + + const handleToggleConfirm = () => { + if (!toggleTarget) return; + toggleActive( + { id: toggleTarget.id, activate: !toggleTarget.isActive }, + { onSuccess: () => setToggleTarget(null) }, + ); + }; + + const columns = useMemo( + () => + getOrgAdminsColumnDefn({ + selectedOrgId: selectedOrg?.id ?? "", + localizedName: localizedName as (name?: { + am?: string; + en?: string; + }) => string, + onEdit: (admin) => { + setEditAdmin(admin); + setFormOpen(true); + }, + onResend: (admin) => + resendInvite({ + email: admin.email ?? "", + phoneNumber: admin.phoneNumber ?? "", + }), + onToggleActive: setToggleTarget, + onRemove: (admin, roleInfo) => setRemoveTarget({ admin, roleInfo }), + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [selectedOrg?.id], + ); + + return ( +
+ + + + {t("orgAdmins.title")} + +

+ {t("orgAdmins.subtitle")} +

+
+ + {/* Org selector + summary */} +
+ + {selectedOrg && ( +
+ + + {t("orgAdmins.adminsCount", { count: adminCount })} + + {selectedOrg.activeEmployeeCount !== undefined && ( + + {t("orgAdmins.activeEmployees", { + count: selectedOrg.activeEmployeeCount, + })} + + )} + + {selectedOrg.status} + +
+ )} +
+ + {!selectedOrg ? ( +
+ +

+ {t("orgAdmins.selectOrgPrompt")} +

+

+ {t("orgAdmins.selectOrgPromptHint")} +

+
+ ) : ( + <> + {!isLoading && adminCount === 0 && ( +
+ {t("orgAdmins.noAdminsHint", { + name: localizedName(selectedOrg.name), + })} +
+ )} + setPageIndex(pageIndex + 1)} + prevFunction={() => setPageIndex(Math.max(pageIndex - 1, 0))} + refresh={refetch} + isLoading={isLoading} + extraToolbar={ +
+ + +
+ } + /> + + )} +
+
+ + {/* Invite / Edit modal */} + { + setFormOpen(false); + setEditAdmin(null); + }} + admin={editAdmin} + onSubmit={handleFormSubmit} + isSubmitting={isInviting || isUpdatingProfile} + /> + + {/* Assign existing employee modal */} + {selectedOrg && ( + setAssignOpen(false)} + organizationId={selectedOrg.id} + existingAdminIds={existingAdminIds} + onAssign={handleAssign} + isAssigning={isAssigning} + /> + )} + + {/* Remove admin confirm */} + !open && setRemoveTarget(null)} + > + + + + {t("orgAdmins.confirmRemove.title")} + + + {t("orgAdmins.confirmRemove.description", { + name: + localizedName(removeTarget?.admin.name) || + removeTarget?.admin.email, + org: localizedName(selectedOrg?.name), + })} + + + + + {t("common.cancel")} + + + {isRemoving + ? t("orgAdmins.confirmRemove.removing") + : t("orgAdmins.actions.remove")} + + + + + + {/* Activate / Deactivate confirm */} + !open && setToggleTarget(null)} + > + + + + {toggleTarget?.isActive + ? t("orgAdmins.confirmToggle.deactivateTitle") + : t("orgAdmins.confirmToggle.activateTitle")} + + + {t("orgAdmins.confirmToggle.description", { + name: + localizedName(toggleTarget?.name) || toggleTarget?.email, + })} + + + + + {t("common.cancel")} + + + {toggleTarget?.isActive + ? t("orgAdmins.actions.deactivate") + : t("orgAdmins.actions.activate")} + + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx new file mode 100644 index 000000000..bff9172c7 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx @@ -0,0 +1,142 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useDebouncedValue } from "@mantine/hooks"; +import { Building2, Check, ChevronsUpDown, Loader2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/shared/common/ui/button"; +import { Badge } from "@/shared/common/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/common/ui/popover"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@/shared/common/ui/command"; +import { cn } from "@/super-admin/lib/utils"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { getOrganizationsWithAdminFlag } from "@/shared/services/organizationsService"; +import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; +import { ORG_PICKER_KEY } from "@/super-admin/hooks/useOrgAdmins"; + +interface OrgPickerProps { + value: OrganizationDto | null; + onChange: (org: OrganizationDto) => void; +} + +/** Searchable organization combobox — server-side name search, shows admin counts. */ +export function OrgPicker({ value, onChange }: OrgPickerProps) { + const { t } = useTranslation(); + const localizedName = useLocalizedName(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebouncedValue(search, 300); + + const { data: orgsResponse, isLoading } = useQuery({ + queryKey: [ORG_PICKER_KEY, debouncedSearch], + queryFn: async () => { + const { data } = await getOrganizationsWithAdminFlag({ + take: 50, + skip: 0, + name: debouncedSearch || undefined, + }); + return { + count: (data?.count ?? 0) as number, + items: (data?.items ?? []) as OrganizationDto[], + }; + }, + staleTime: 5 * 60 * 1000, + retry: false, + }); + + const orgs = orgsResponse?.items ?? []; + + return ( + + + + + + + + + {isLoading ? ( +
+ +
+ ) : ( + <> + {t("orgAdmins.noOrgsFound")} + {orgs.map((org) => ( + { + onChange(org); + setOpen(false); + }} + className="flex items-center justify-between gap-2" + > + + + + {localizedName(org.name)} + + + {(org.adminsCount ?? 0) > 0 ? ( + + {t("orgAdmins.adminsCount", { + count: org.adminsCount ?? 0, + })} + + ) : ( + + {t("orgAdmins.noAdmins")} + + )} + + ))} + + )} +
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/AssignOrgAdminDialog.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/AssignOrgAdminDialog.tsx deleted file mode 100644 index 2ac9ef71d..000000000 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/AssignOrgAdminDialog.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { FormEvent, useMemo, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { Check, Loader2 } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; - -import { Button } from "@/shared/common/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/shared/common/ui/dialog"; -import { Input } from "@/shared/common/ui/input"; -import { Label } from "@/shared/common/ui/label"; -import { ScrollArea } from "@/shared/common/ui/scroll-area"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { cn } from "@/super-admin/lib/utils"; -import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; -import { - assignOrgAdminRole, - RemoveOrAssignOrgAdminPayload, -} from "@/super-admin/services/api/userRoleService"; -import { useEmployees } from "@/user-management/hooks/useEmployees"; - -interface AssignOrgAdminDialogProps { - isOpen: boolean; - onClose: () => void; - onSuccess: () => void; -} - -export function AssignOrgAdminDialog({ - isOpen, - onClose, - onSuccess, -}: AssignOrgAdminDialogProps) { - const queryClient = useQueryClient(); - const { t } = useTranslation(); - const localizedName = useLocalizedName(); - const [selectedOrg, setSelectedOrg] = useState(""); - const [selectedUser, setSelectedUser] = useState(""); - const [search, setSearch] = useState(""); - const [isAssigning, setIsAssigning] = useState(false); - - const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( - "Org", - { take: 300 } - ); - - const { - employeesResponseByOrg, - isLoadingEmployeesByOrg: isLoadingEmployees, - } = useEmployees({ - organizationId: selectedOrg || undefined, - params: { take: 3000, skip: 0 }, - }); - - const filteredEmployees = useMemo(() => { - const query = search.trim().toLowerCase(); - const employees = employeesResponseByOrg?.items ?? []; - - if (!query) return employees; - - return employees.filter((employee) => { - const name = localizedName(employee.user.name).toLowerCase(); - const email = employee.user.email?.toLowerCase() ?? ""; - - return name.includes(query) || email.includes(query); - }); - }, [employeesResponseByOrg, localizedName, search]); - - const resetForm = () => { - setSelectedOrg(""); - setSelectedUser(""); - setSearch(""); - }; - - const handleClose = () => { - if (isAssigning) return; - resetForm(); - onClose(); - }; - - const handleOrganizationChange = (organizationId: string) => { - setSelectedOrg(organizationId); - setSelectedUser(""); - setSearch(""); - }; - - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - - if (!selectedOrg || !selectedUser) { - toast.error(t("organization.allFieldsRequired", "All fields are required")); - return; - } - - const payload: RemoveOrAssignOrgAdminPayload = { - organizationId: selectedOrg, - userId: selectedUser, - }; - - setIsAssigning(true); - - try { - await assignOrgAdminRole(payload); - await queryClient.invalidateQueries({ - queryKey: ["organizationAdmins"], - }); - resetForm(); - onClose(); - onSuccess(); - } catch (error: any) { - toast.error(t("organization.userAssignFailed"), { - description: error?.response?.data?.message, - }); - } finally { - setIsAssigning(false); - } - }; - - return ( - { - if (!open) handleClose(); - }} - > - - - - {t( - "organization.assignAdminToOrganization", - "Assign admin to organization" - )} - - - {t( - "organization.assignOrgAdminInstructions", - "Select an organization and a user to assign as its administrator." - )} - - - -
-
-
- - {isLoadingOrgs ? ( -
- -
- ) : ( - -
- {organizationsResponse?.items?.map((organization) => ( - - ))} -
-
- )} -
- -
- - {!selectedOrg ? ( -
-

- {t("organization.selectOrganizationFirst")} -

-
- ) : isLoadingEmployees ? ( -
- -
- ) : ( -
- setSearch(event.target.value)} - placeholder={t( - "organization.searchOrganizationUsers", - "Search organization users" - )} - /> - -
- {filteredEmployees.map((employee) => ( - - ))} - {filteredEmployees.length === 0 && ( -

- {t("organization.noUsersFound")} -

- )} -
-
-
- )} -
-
- - - - - -
-
-
- ); -} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdmins.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdmins.tsx deleted file mode 100644 index 81fcaff80..000000000 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdmins.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { useState } from "react"; -import { Button } from "@/shared/common/ui/button"; -import { Link } from "react-router-dom"; -import { Plus, Loader2, UserPlus } from "lucide-react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "../../../shared/common/ui/card"; -import { toast } from "sonner"; -import { AdvancedTable } from "../../../shared/common/ui/table/AdvancedTable"; -import { OrganizationAdminsColumnDefn } from "./OrganizationAdminsColumnDefn"; -import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; -import { AssignOrgAdminDialog } from "./AssignOrgAdminDialog"; -import { useTranslation } from "react-i18next"; -import { useLocalizedName } from "@/shared/common/localizedName"; - -export default function OrganizationAdmins() { - const [pageIndex, setPageIndex] = useState(0); // starts at 0 - const pageSize = 10; - const { t } = useTranslation(); - const localizedName = useLocalizedName(); - const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const { organizationsAdminsResponse, isLoading, isError, refetch } = - useOrganizations("Admin", { - take: pageSize, - skip: pageIndex * pageSize, - orderBy: "createdAt", - order: "createdAt:Desc", - name: searchTerm || undefined, - }); - - const handlePageChange = (newPage: number) => { - setPageIndex(newPage); - }; - - const handleSearchChange = (term: string) => { - setSearchTerm(term); - setPageIndex(0); // Reset to first page when search term changes - }; - - const handleRefresh = () => { - refetch(); - }; - - if (isLoading) { - return ( -
-
- -
- {t("organization.loadingAdmins")} -
-
-
- ); - } - - if (isError) { - return ( -
-
-
- {t("organization.errorLoadingAdmins")} -
- -
-
- ); - } - - return ( - <> -
- - - - {t("organization.organizationAdmins")} - - - - string - )} - data={organizationsAdminsResponse?.items || []} - tableName="Organization Admins" - toolBarPosition="right" - refresh={handleRefresh} - onGlobalFilterChange={handleSearchChange} - disableClientFiltering={true} - extraToolbar={ -
- - - - -
- } - itemCount={organizationsAdminsResponse?.count || 0} - pageIndex={pageIndex} - onPageChange={handlePageChange} - nextFunction={() => handlePageChange(pageIndex + 1)} - prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))} - /> -
-
-
- - {/* Assign Admin Dialog */} - setIsAssignDialogOpen(false)} - onSuccess={() => { - refetch(); - toast.success(t("organization.adminAssignedSuccess")); - }} - /> - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsActions.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsActions.tsx deleted file mode 100644 index 812b0cb29..000000000 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsActions.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from "react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../../../shared/common/ui/dropdown-menu"; -import { Edit, MoreHorizontal, Trash, UserCheck, UserX } from "lucide-react"; -import { Button } from "../../../shared/common/ui/button"; -import { Link } from "react-router-dom"; -import { OrganizationAdmin } from "@/super-admin/services/api/organizationAdminService"; -import { toast } from "sonner"; - -interface OrganizationAdminsActionsProps { - rowData: OrganizationAdmin; -} -const OrganizationAdminsActions: React.FC = ({ - rowData, -}) => { - const handleDeleteClick = () => { - toast.warning("Delete functionality not implemented yet"); - }; - - const handleStatusChange = (status: string) => { - toast.info(`Admin status change to ${status} not implemented yet`); - }; - - return ( - - - - - - Actions - - - - - Edit - - - {rowData.status !== "active" && ( - handleStatusChange("active")} - className="flex items-center" - > - - Activate - - )} - {rowData.status === "active" && ( - handleStatusChange("inactive")} - className="flex items-center" - > - - Deactivate - - )} - handleDeleteClick()} - className="flex items-center" - > - - Delete - - - - ); -}; - -export default OrganizationAdminsActions; diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsColumnDefn.tsx deleted file mode 100644 index 4a1e4d33f..000000000 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsColumnDefn.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { ColumnDef } from "@tanstack/react-table"; -import { Button } from "../../../shared/common/ui/button"; -import { ArrowUpDown, Eye } from "lucide-react"; -import { OrganizationAdminsDto } from "@/shared/dto/organization/organizationDto"; -import { StatusCell } from "./StatusCell"; -import { t } from "i18next"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { Link } from "react-router-dom"; -import OrganizationAdminsActions from "./OrganizationAdminsActions"; - -export const OrganizationAdminsColumnDefn = ( - localizedName: (name?: { am?: string; en?: string }) => string, -): ColumnDef[] => { - return [ - { - accessorKey: "name", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => ( -
{localizedName(row.original.name)}
- ), - }, - { - accessorKey: "status", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const id = row.original.id; - return ( - - ); - }, - }, - { - accessorKey: "actions", - header: t("organization.actions") || "Actions", - cell: ({ row }) => ( -
- - - - -
- ), - }, - ]; -}; diff --git a/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts b/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts new file mode 100644 index 000000000..6c50a8907 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts @@ -0,0 +1,199 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; +import { + assignOrganizationAdmin, + fetchAllUnitAdminById, + OrganizationAdminPayload, +} from "@/super-admin/services/api/organizationAdminService"; +import { + assignOrgAdminRole, + removeOrgAdminRole, + removeUnitAdminRole, +} from "@/super-admin/services/api/userRoleService"; +import { + activateUser, + deactivateUser, +} from "@/super-admin/services/api/userService"; +import { resendVerificationCode } from "@/shared/services/authService"; +import { + updateProfile, + UpdateProfilePayload, +} from "@/user-management/services/api/employeePositionsService"; + +export interface OrgAdminUserRole { + id: string; + organizationId?: string | null; + unitId?: string | null; + role?: { key?: string } | null; +} + +/** User row returned by GET /organizations/all-admins/:id (userRoles.role relation included). */ +export interface OrgAdminUser { + id: string; + name?: { am?: string; en?: string }; + username?: string; + email?: string; + phoneNumber?: string; + isActive: boolean; + hasSetPassword: boolean; + status?: string; + createdAt?: string; + userRoles?: OrgAdminUserRole[]; +} + +export const ORG_ADMINS_KEY = "orgAdmins"; +export const ORG_PICKER_KEY = "orgAdminsOrgPicker"; + +export interface RemoveAdminInput { + userId: string; + /** set for org-admin removal */ + organizationId?: string; + /** set for unit-admin removal (wins over organizationId) */ + unitId?: string; +} + +export const useOrgAdmins = ( + orgId?: string, + params?: { take?: number; skip?: number }, +) => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + const { + data: adminsResponse, + isLoading, + isError, + refetch, + } = useQuery({ + queryKey: [ORG_ADMINS_KEY, orgId, params], + queryFn: async () => { + const { data } = await fetchAllUnitAdminById(orgId as string, params); + return { + count: (data?.count ?? 0) as number, + items: (data?.items ?? []) as OrgAdminUser[], + }; + }, + enabled: !!orgId, + staleTime: 5 * 60 * 1000, + retry: false, + }); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: [ORG_ADMINS_KEY] }); + // picker + org tables show adminsCount — keep them fresh + queryClient.invalidateQueries({ queryKey: [ORG_PICKER_KEY] }); + queryClient.invalidateQueries({ queryKey: ["organizations"] }); + queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] }); + }; + + const { mutate: inviteAdmin, isPending: isInviting } = useMutation({ + mutationFn: async (payload: OrganizationAdminPayload) => { + const { data } = await assignOrganizationAdmin(payload); + return data; + }, + onSuccess: () => { + toast.success(t("orgAdmins.toasts.invited")); + invalidate(); + }, + onError: handleError, + }); + + const { mutate: assignAdmin, isPending: isAssigning } = useMutation({ + mutationFn: async (payload: { organizationId: string; userId: string }) => { + const { data } = await assignOrgAdminRole(payload); + return data; + }, + onSuccess: () => { + toast.success(t("orgAdmins.toasts.assigned")); + invalidate(); + }, + onError: handleError, + }); + + const { mutate: removeAdmin, isPending: isRemoving } = useMutation({ + mutationFn: async ({ userId, organizationId, unitId }: RemoveAdminInput) => { + const { data } = unitId + ? await removeUnitAdminRole({ unitId, userId }) + : await removeOrgAdminRole({ + organizationId: organizationId as string, + userId, + }); + return data; + }, + onSuccess: () => { + toast.success(t("orgAdmins.toasts.removed")); + invalidate(); + }, + onError: handleError, + }); + + const { mutate: resendInvite, isPending: isResending } = useMutation({ + mutationFn: async (payload: { email: string; phoneNumber: string }) => { + const { data } = await resendVerificationCode(payload); + return data; + }, + onSuccess: () => { + toast.success(t("orgAdmins.toasts.resent")); + }, + onError: handleError, + }); + + const { mutate: toggleActive, isPending: isToggling } = useMutation({ + mutationFn: async ({ id, activate }: { id: string; activate: boolean }) => { + const { data } = activate + ? await activateUser(id) + : await deactivateUser(id); + return data; + }, + onSuccess: (_data, variables) => { + toast.success( + variables.activate + ? t("orgAdmins.toasts.activated") + : t("orgAdmins.toasts.deactivated"), + ); + invalidate(); + }, + onError: handleError, + }); + + const { mutate: updateAdminProfile, isPending: isUpdatingProfile } = + useMutation({ + mutationFn: async ({ + id, + payload, + }: { + id: string; + payload: UpdateProfilePayload; + }) => { + const { data } = await updateProfile(payload, id); + return data; + }, + onSuccess: () => { + toast.success(t("orgAdmins.toasts.profileUpdated")); + invalidate(); + }, + onError: handleError, + }); + + return { + adminsResponse, + isLoading, + isError, + refetch, + inviteAdmin, + isInviting, + assignAdmin, + isAssigning, + removeAdmin, + isRemoving, + resendInvite, + isResending, + toggleActive, + isToggling, + updateAdminProfile, + isUpdatingProfile, + }; +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx index 8c06bf50e..d525aa4f8 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx @@ -280,7 +280,7 @@ export const CreatePositionForm = ({ navigate("/user-management/position-management"); } } catch { - toast.error(t("contentManagement.permissionSuccess")); + toast.error(t("contentManagement.permissionFailed")); } }; From d47e3ae4f516055f150c2285f73d7c9f9dd2974b Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 23 Jul 2026 11:48:08 +0000 Subject: [PATCH 2/2] docker exefix: reimplement the admin form --- .../src/locales/am/translation.json | 33 +- .../src/locales/en/translation.json | 33 +- apps/edr-freight-web/backoffice/src/main.tsx | 4 + .../components/org-admins/AdminFormModal.tsx | 210 +++++++++++-- .../org-admins/AssignExistingAdminModal.tsx | 291 ++++++++++++++---- .../components/org-admins/OrgAdminsPage.tsx | 114 +++++-- .../components/org-admins/OrgPicker.tsx | 25 +- .../src/super-admin/hooks/useOrgAdmins.ts | 90 +++++- 8 files changed, 659 insertions(+), 141 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/locales/am/translation.json b/apps/edr-freight-web/backoffice/src/locales/am/translation.json index 4f3daeaee..dcf029204 100644 --- a/apps/edr-freight-web/backoffice/src/locales/am/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/am/translation.json @@ -7405,7 +7405,6 @@ "selectOrgPrompt": "አስተዳዳሪዎቹን ለማስተዳደር ድርጅት ይምረጡ", "selectOrgPromptHint": "ከላይ ያለውን መምረጫ ተጠቅመው ድርጅት ይፈልጉ እና ይምረጡ።", "noAdminsHint": "{{name}} እስካሁን አስተዳዳሪ የለውም። አዲስ አስተዳዳሪ ይጋብዙ ወይም ነባር ሰራተኛ ይመድቡ።", - "inviteAdmin": "አስተዳዳሪ ይጋብዙ", "assignExisting": "ነባር ሰራተኛ ይመድቡ", "roleOrgAdmin": "የድርጅት አስተዳዳሪ", "roleUnitAdmin": "የክፍል አስተዳዳሪ", @@ -7433,12 +7432,12 @@ "nameAm": "ስም (አማርኛ)", "username": "የተጠቃሚ ስም", "email": "ኢሜይል", - "phoneNumber": "ስልክ ቁጥር" - }, - "invite": { - "title": "አዲስ አስተዳዳሪ ይጋብዙ", - "description": "ተጋባዡ የይለፍ ቃል ለማዘጋጀት እና መለያውን ለማንቃት አገናኝ በSMS ይደርሰዋል።", - "submit": "ግብዣ ይላኩ" + "phoneNumber": "ስልክ ቁጥር", + "unit": "ክፍል", + "selectUnit": "ክፍል ይምረጡ", + "loadingUnits": "ክፍሎች በመጫን ላይ...", + "noUnit": "ምንም — የድርጅት አስተዳዳሪ", + "unitRequired": "ክፍል ያስፈልጋል" }, "edit": { "title": "የአስተዳዳሪ መገለጫ ያስተካክሉ", @@ -7451,7 +7450,9 @@ "searchUsers": "ሰራተኞችን በስም ወይም በኢሜይል ይፈልጉ...", "noUsersFound": "ምንም ሰራተኛ አልተገኘም።", "alreadyAdmin": "አስቀድሞ አስተዳዳሪ ነው", - "submit": "እንደ አስተዳዳሪ ይመድቡ" + "submit": "እንደ አስተዳዳሪ ይመድቡ", + "loadError": "ሰራተኞችን መጫን አልተሳካም።", + "users": "ተጠቃሚዎች" }, "confirmRemove": { "title": "አስተዳዳሪ ይወገድ?", @@ -7464,13 +7465,25 @@ "description": "ይህ የ{{name}}ን የመለያ ሁኔታ በመላው ስርዓቱ ላይ ይቀይራል፣ ለዚህ ድርጅት ብቻ አይደለም።" }, "toasts": { - "invited": "ግብዣው በተሳካ ሁኔታ ተልኳል!", "assigned": "አስተዳዳሪ በተሳካ ሁኔታ ተመድቧል!", "removed": "አስተዳዳሪ በተሳካ ሁኔታ ተወግዷል!", "resent": "ግብዣው እንደገና ተልኳል!", "activated": "መለያው ነቅቷል!", "deactivated": "መለያው ቦዝኗል!", - "profileUpdated": "መገለጫው ተዘምኗል!" + "profileUpdated": "መገለጫው ተዘምኗል!", + "resending": "ግብዣ በመላክ ላይ...", + "missingContact": "ይህ አስተዳዳሪ ኢሜይል ወይም ስልክ ቁጥር የለውም።", + "added": "አስተዳዳሪ በተሳካ ሁኔታ ተጨምሯል!" + }, + "loadError": "አስተዳዳሪዎችን መጫን አልተሳካም።", + "pickerError": "ድርጅቶችን መጫን አልተሳካም።", + "addAdmin": "አስተዳዳሪ ጨምር", + "add": { + "title": "አስተዳዳሪ ጨምር", + "description": "የተጠቃሚ መለያ ይፍጠሩ እና በዚህ ድርጅት ውስጥ የአስተዳዳሪ መዳረሻ ይስጡ።", + "submit": "አስተዳዳሪ ጨምር", + "inviteNote": "ተጠቃሚው ይፈጠራል እና የይለፍ ቃሉን እንዲያዘጋጅ የኤስኤምኤስ ግብዣ ይደርሰዋል።", + "noUnitsOrgAdmin": "ይህ ድርጅት ክፍሎች የሉትም — አስተዳዳሪው እንደ የድርጅት አስተዳዳሪ ይጨመራል።" } } } diff --git a/apps/edr-freight-web/backoffice/src/locales/en/translation.json b/apps/edr-freight-web/backoffice/src/locales/en/translation.json index c300cb27a..81bf4d519 100644 --- a/apps/edr-freight-web/backoffice/src/locales/en/translation.json +++ b/apps/edr-freight-web/backoffice/src/locales/en/translation.json @@ -7406,7 +7406,6 @@ "selectOrgPrompt": "Select an organization to manage its admins", "selectOrgPromptHint": "Use the selector above to search and pick an organization.", "noAdminsHint": "{{name}} has no administrators yet. Invite a new admin or assign an existing employee.", - "inviteAdmin": "Invite Admin", "assignExisting": "Assign Existing", "roleOrgAdmin": "Org Admin", "roleUnitAdmin": "Unit Admin", @@ -7434,12 +7433,12 @@ "nameAm": "Name (Amharic)", "username": "Username", "email": "Email", - "phoneNumber": "Phone Number" - }, - "invite": { - "title": "Invite New Admin", - "description": "The invitee receives an SMS with a link to set their password and activate the account.", - "submit": "Send Invite" + "phoneNumber": "Phone Number", + "unit": "Unit", + "selectUnit": "Select a unit", + "loadingUnits": "Loading units...", + "noUnit": "None — organization admin", + "unitRequired": "Unit is required" }, "edit": { "title": "Edit Admin Profile", @@ -7452,7 +7451,9 @@ "searchUsers": "Search employees by name or email...", "noUsersFound": "No employees found.", "alreadyAdmin": "Already admin", - "submit": "Assign as Admin" + "submit": "Assign as Admin", + "loadError": "Failed to load employees.", + "users": "Users" }, "confirmRemove": { "title": "Remove Admin?", @@ -7465,13 +7466,25 @@ "description": "This changes the account status of {{name}} across the whole platform, not just for this organization." }, "toasts": { - "invited": "Invitation sent successfully!", "assigned": "Admin assigned successfully!", "removed": "Admin removed successfully!", "resent": "Invitation re-sent successfully!", "activated": "Account activated successfully!", "deactivated": "Account deactivated successfully!", - "profileUpdated": "Profile updated successfully!" + "profileUpdated": "Profile updated successfully!", + "resending": "Sending invite...", + "missingContact": "This admin has no email or phone number on file.", + "added": "Admin added successfully!" + }, + "loadError": "Failed to load admins.", + "pickerError": "Failed to load organizations.", + "addAdmin": "Add Admin", + "add": { + "title": "Add Admin", + "description": "Create a user account and grant admin access in this organization.", + "submit": "Add Admin", + "inviteNote": "The user is created and receives an SMS invitation to set their password.", + "noUnitsOrgAdmin": "This organization has no units — the admin will be added as an organization admin." } } } diff --git a/apps/edr-freight-web/backoffice/src/main.tsx b/apps/edr-freight-web/backoffice/src/main.tsx index eacd05459..aad3bbb89 100644 --- a/apps/edr-freight-web/backoffice/src/main.tsx +++ b/apps/edr-freight-web/backoffice/src/main.tsx @@ -11,6 +11,7 @@ import "../index.css"; import "@edr/ui-common/theme.css"; import { Toaster } from "react-hot-toast"; +import { Toaster as SonnerToaster } from "./shared/common/ui/sonner"; // Initialize i18next before first paint so the detected/persisted language // applies immediately (the vendored IAM UI also imports this via @/i18n). @@ -70,6 +71,9 @@ createRoot(rootElement).render( message (suppressed on warehouse / mile / onboarding pages). */} + {/* sonner toasts (used across super-admin & user-management) + rendered nowhere without this mount */} + diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AdminFormModal.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AdminFormModal.tsx index 8fff2764d..44211f6c3 100644 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AdminFormModal.tsx +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AdminFormModal.tsx @@ -22,7 +22,17 @@ import { FormLabel, FormMessage, } from "@/shared/common/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; import { Input } from "@/shared/common/ui/input"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; import { OrgAdminUser } from "@/super-admin/hooks/useOrgAdmins"; const adminSchema = z.object({ @@ -35,6 +45,10 @@ const adminSchema = z.object({ phoneNumber: z .string() .regex(/^(\+251|0)?9\d{8}$/, t("organization.invalidPhoneNumber")), + organizationId: z.string().min(1, t("organization.organizationRequired")), + /** required when the org has units (unit admin); empty only when the org + * has no units → org admin. Enforced at submit, not in the schema. */ + unitId: z.string().optional(), }); export type AdminFormValues = z.infer; @@ -44,26 +58,37 @@ const EMPTY_VALUES: AdminFormValues = { username: "", email: "", phoneNumber: "", + organizationId: "", + unitId: "", }; +const RequiredMark = () => *; + interface AdminFormModalProps { isOpen: boolean; onClose: () => void; - /** null → invite a new admin; set → edit this admin's profile */ + /** null → add a new admin; set → edit this admin's profile */ admin: OrgAdminUser | null; + /** org whose units feed the unit-admin scope picker */ + organizationId?: string; + /** server-side error from the last submit, shown inline */ + apiError: string | null; onSubmit: (values: AdminFormValues) => void; isSubmitting: boolean; } -/** Invite-new-admin / edit-admin-profile modal (same fields, one form). */ +/** Add-admin (org or unit scope) / edit-admin-profile modal (one form). */ export default function AdminFormModal({ isOpen, onClose, admin, + organizationId, + apiError, onSubmit, isSubmitting, }: AdminFormModalProps) { const { t } = useTranslation(); + const localizedName = useLocalizedName(); const isEdit = !!admin; const form = useForm({ @@ -71,11 +96,26 @@ export default function AdminFormModal({ defaultValues: EMPTY_VALUES, }); + const { organizationsResponse } = useOrganizations("Org", { take: 3000 }); + const activeOrgs = (organizationsResponse?.items ?? []).filter( + (org) => org.status === "Active", + ); + + const selectedOrgId = form.watch("organizationId"); + const { data: unitsResponse, isLoading: isLoadingUnits } = + useUnit().getList( + selectedOrgId || "", + { take: 300, skip: 0 }, + isOpen && !isEdit, + ); + const units = unitsResponse?.data?.items ?? []; + useEffect(() => { if (isOpen) { form.reset( admin ? { + ...EMPTY_VALUES, name: { en: admin.name?.en ?? "", am: admin.name?.am ?? "", @@ -84,10 +124,22 @@ export default function AdminFormModal({ email: admin.email ?? "", phoneNumber: admin.phoneNumber ?? "", } - : EMPTY_VALUES, + : { ...EMPTY_VALUES, organizationId: organizationId ?? "" }, ); } - }, [isOpen, admin, form]); + }, [isOpen, admin, organizationId, form]); + + // an org with units gets a unit admin — unit is mandatory then; only a + // unit-less org falls through to an org admin + const submit = form.handleSubmit((values) => { + if (!isEdit && units.length > 0 && !values.unitId) { + form.setError("unitId", { + message: t("orgAdmins.form.unitRequired"), + }); + return; + } + onSubmit(values); + }); const handleClose = () => { if (isSubmitting) return; @@ -99,26 +151,32 @@ export default function AdminFormModal({ - {isEdit ? t("orgAdmins.edit.title") : t("orgAdmins.invite.title")} + {isEdit ? t("orgAdmins.edit.title") : t("orgAdmins.add.title")} {isEdit ? t("orgAdmins.edit.description") - : t("orgAdmins.invite.description")} + : t("orgAdmins.add.description")}
- +
( - {t("orgAdmins.form.nameEn")} + + {t("orgAdmins.form.nameEn")} + + - + @@ -129,9 +187,15 @@ export default function AdminFormModal({ name="name.am" render={({ field }) => ( - {t("orgAdmins.form.nameAm")} + + {t("orgAdmins.form.nameAm")} + + - + @@ -143,9 +207,15 @@ export default function AdminFormModal({ name="username" render={({ field }) => ( - {t("orgAdmins.form.username")} + + {t("orgAdmins.form.username")} + + - + @@ -156,11 +226,14 @@ export default function AdminFormModal({ name="email" render={({ field }) => ( - {t("orgAdmins.form.email")} + + {t("orgAdmins.form.email")} + + @@ -173,10 +246,14 @@ export default function AdminFormModal({ name="phoneNumber" render={({ field }) => ( - {t("orgAdmins.form.phoneNumber")} + + {t("orgAdmins.form.phoneNumber")} + + @@ -185,6 +262,98 @@ export default function AdminFormModal({ )} /> + {!isEdit && ( + <> + ( + + + {t("organization.organization")} + + + + + + )} + /> + {(isLoadingUnits || units.length > 0 || !selectedOrgId) && ( + ( + + + {t("orgAdmins.form.unit")} + + + + + + )} + /> + )} +

+ {!isLoadingUnits && selectedOrgId && units.length === 0 + ? t("orgAdmins.add.noUnitsOrgAdmin") + : t("orgAdmins.add.inviteNote")} +

+ + )} + + {apiError && ( +
+ {apiError} +
+ )} + - diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx index 0390f2671..870d75486 100644 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Check, Loader2, UserPlus } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/shared/common/ui/button"; @@ -11,28 +11,41 @@ import { DialogTitle, } from "@/shared/common/ui/dialog"; import { Input } from "@/shared/common/ui/input"; +import { Label } from "@/shared/common/ui/label"; import { ScrollArea } from "@/shared/common/ui/scroll-area"; import { Badge } from "@/shared/common/ui/badge"; import { cn } from "@/super-admin/lib/utils"; import { useLocalizedName } from "@/shared/common/localizedName"; import { useEmployees } from "@/user-management/hooks/useEmployees"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; interface AssignExistingAdminModalProps { isOpen: boolean; onClose: () => void; + /** org preselected in the panel (the page's current org) */ organizationId: string; - /** user ids that are already admins — shown disabled */ + /** user ids that are already admins of the page's org — shown disabled */ existingAdminIds: string[]; - onAssign: (userId: string) => void; + /** server-side error from the last assign attempt, shown inline */ + apiError: string | null; + /** unitId set → grant unit-admin of that unit instead of org-admin */ + onAssign: (userId: string, organizationId: string, unitId?: string) => void; isAssigning: boolean; } -/** Promote an existing employee of the selected organization to org admin. */ +/** + * Promote an existing employee to admin. Three panels like the old + * AssignAdminDialog: pick an org (page org preselected), pick a unit (or + * none → org admin), then pick a user — unit selection also filters the + * employee list to that unit. + */ export default function AssignExistingAdminModal({ isOpen, onClose, organizationId, existingAdminIds, + apiError, onAssign, isAssigning, }: AssignExistingAdminModalProps) { @@ -40,9 +53,28 @@ export default function AssignExistingAdminModal({ const localizedName = useLocalizedName(); const [selectedUserId, setSelectedUserId] = useState(""); const [search, setSearch] = useState(""); + const [orgId, setOrgId] = useState(organizationId); + // "" → org admin (all org users listed); set → unit admin of that unit + const [unitId, setUnitId] = useState(""); - const { employeesResponseByOrg, isLoadingEmployeesByOrg } = useEmployees({ - organizationId: isOpen ? organizationId : undefined, + const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( + "Org", + { take: 300 }, + ); + const orgs = organizationsResponse?.items ?? []; + + const { data: unitsResponse, isLoading: isLoadingUnits } = + useUnit().getList(orgId, { take: 300, skip: 0 }, isOpen); + const units = unitsResponse?.data?.items ?? []; + + const { + employeesResponseByOrg, + isLoadingEmployeesByOrg, + isErrorEmployeesByOrg, + refetchEmployeesByOrg, + } = useEmployees({ + organizationId: isOpen ? orgId : undefined, + unitId: unitId || undefined, params: { take: 3000, skip: 0 }, }); @@ -57,16 +89,41 @@ export default function AssignExistingAdminModal({ }); }, [employeesResponseByOrg, localizedName, search]); + const selectOrg = (id: string) => { + setOrgId(id); + setUnitId(""); + setSelectedUserId(""); + }; + + const selectUnit = (id: string) => { + setUnitId(id); + setSelectedUserId(""); + }; + const handleClose = () => { if (isAssigning) return; setSelectedUserId(""); setSearch(""); + setOrgId(organizationId); + setUnitId(""); onClose(); }; + // already-admin info only covers the page's org + const knownAdminIds = orgId === organizationId ? existingAdminIds : []; + + useEffect(() => { + if (isOpen) { + setOrgId(organizationId); + setUnitId(""); + setSelectedUserId(""); + setSearch(""); + } + }, [isOpen, organizationId]); + return ( !open && handleClose()}> - + {t("orgAdmins.assign.title")} @@ -74,69 +131,179 @@ export default function AssignExistingAdminModal({ -
- setSearch(event.target.value)} - placeholder={t("orgAdmins.assign.searchUsers")} - /> - {isLoadingEmployeesByOrg ? ( -
- -
- ) : ( - -
- {filteredEmployees.map((employee: any) => { - const userId = employee.user?.id; - if (!userId) return null; - const isAlreadyAdmin = existingAdminIds.includes(userId); - return ( +
+ {/* Step 1: organization (page org preselected) */} +
+ + {isLoadingOrgs ? ( +
+ +
+ ) : ( + +
+ {orgs.map((org) => ( - ); - })} - {filteredEmployees.length === 0 && ( -

- {t("orgAdmins.assign.noUsersFound")} -

- )} + ))} +
+
+ )} +
+ + {/* Step 2: unit (or none → org admin) */} +
+ + {isLoadingUnits ? ( +
+
- - )} + ) : ( + +
+ + {units.map((unit: any) => ( + + ))} +
+
+ )} +
+ + {/* Step 3: user */} +
+ + setSearch(event.target.value)} + placeholder={t("orgAdmins.assign.searchUsers")} + /> + {isLoadingEmployeesByOrg ? ( +
+ +
+ ) : isErrorEmployeesByOrg ? ( +
+ {t("orgAdmins.assign.loadError")} + +
+ ) : ( + +
+ {filteredEmployees.map((employee: any) => { + const userId = employee.user?.id; + if (!userId) return null; + const isAlreadyAdmin = knownAdminIds.includes(userId); + return ( + + ); + })} + {filteredEmployees.length === 0 && ( +

+ {t("orgAdmins.assign.noUsersFound")} +

+ )} +
+
+ )} +
+ {apiError && ( +
+ {apiError} +
+ )} +
) : ( <> - {!isLoading && adminCount === 0 && ( + {isError && ( +
+ {t("orgAdmins.loadError")} + +
+ )} + {!isLoading && !isError && adminCount === 0 && (
{t("orgAdmins.noAdminsHint", { name: localizedName(selectedOrg.name), @@ -250,7 +288,7 @@ export default function OrgAdminsPage() { }} > - {t("orgAdmins.inviteAdmin")} + {t("orgAdmins.addAdmin")}
} @@ -260,25 +298,32 @@ export default function OrgAdminsPage() { - {/* Invite / Edit modal */} + {/* Add / Edit modal */} { setFormOpen(false); setEditAdmin(null); + clearErrors(); }} admin={editAdmin} + organizationId={selectedOrg?.id} + apiError={formError} onSubmit={handleFormSubmit} - isSubmitting={isInviting || isUpdatingProfile} + isSubmitting={isAdding || isUpdatingProfile} /> {/* Assign existing employee modal */} {selectedOrg && ( setAssignOpen(false)} + onClose={() => { + setAssignOpen(false); + clearErrors(); + }} organizationId={selectedOrg.id} existingAdminIds={existingAdminIds} + apiError={assignError} onAssign={handleAssign} isAssigning={isAssigning} /> @@ -287,7 +332,12 @@ export default function OrgAdminsPage() { {/* Remove admin confirm */} !open && setRemoveTarget(null)} + onOpenChange={(open) => { + if (!open) { + setRemoveTarget(null); + clearErrors(); + } + }} > @@ -303,6 +353,11 @@ export default function OrgAdminsPage() { })} + {removeError && ( +
+ {removeError} +
+ )} {t("common.cancel")} @@ -323,7 +378,12 @@ export default function OrgAdminsPage() { {/* Activate / Deactivate confirm */} !open && setToggleTarget(null)} + onOpenChange={(open) => { + if (!open) { + setToggleTarget(null); + clearErrors(); + } + }} > @@ -339,6 +399,11 @@ export default function OrgAdminsPage() { })} + {toggleError && ( +
+ {toggleError} +
+ )} {t("common.cancel")} @@ -352,6 +417,7 @@ export default function OrgAdminsPage() { : "bg-emerald-600 hover:bg-emerald-700 text-white" } > + {isToggling && } {toggleTarget?.isActive ? t("orgAdmins.actions.deactivate") : t("orgAdmins.actions.activate")} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx index bff9172c7..cc32d4a5d 100644 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@mantine/hooks"; import { Building2, Check, ChevronsUpDown, Loader2 } from "lucide-react"; @@ -36,7 +36,12 @@ export function OrgPicker({ value, onChange }: OrgPickerProps) { const [search, setSearch] = useState(""); const [debouncedSearch] = useDebouncedValue(search, 300); - const { data: orgsResponse, isLoading } = useQuery({ + const { + data: orgsResponse, + isLoading, + isError, + refetch, + } = useQuery({ queryKey: [ORG_PICKER_KEY, debouncedSearch], queryFn: async () => { const { data } = await getOrganizationsWithAdminFlag({ @@ -55,6 +60,15 @@ export function OrgPicker({ value, onChange }: OrgPickerProps) { const orgs = orgsResponse?.items ?? []; + // default to the first org that already has admins (initial load only, + // never while the user is searching) + useEffect(() => { + if (value || debouncedSearch || !orgs.length) return; + const firstWithAdmins = orgs.find((org) => (org.adminsCount ?? 0) > 0); + if (firstWithAdmins) onChange(firstWithAdmins); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orgsResponse]); + return ( @@ -89,6 +103,13 @@ export function OrgPicker({ value, onChange }: OrgPickerProps) {
+ ) : isError ? ( +
+ {t("orgAdmins.pickerError")} + +
) : ( <> {t("orgAdmins.noOrgsFound")} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts b/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts index 6c50a8907..57c5cddc2 100644 --- a/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts +++ b/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts @@ -1,14 +1,16 @@ +import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; import { assignOrganizationAdmin, + assignUnitAdmin, fetchAllUnitAdminById, - OrganizationAdminPayload, } from "@/super-admin/services/api/organizationAdminService"; import { assignOrgAdminRole, + assignUnitAdminRole, removeOrgAdminRole, removeUnitAdminRole, } from "@/super-admin/services/api/userRoleService"; @@ -54,13 +56,42 @@ export interface RemoveAdminInput { unitId?: string; } +export interface AddAdminInput { + organizationId: string; + /** set → create as unit admin of this unit instead of org admin */ + unitId?: string; + name: { am: string; en: string }; + username: string; + email: string; + phoneNumber: string; +} + export const useOrgAdmins = ( orgId?: string, params?: { take?: number; skip?: number }, ) => { const queryClient = useQueryClient(); const { t } = useTranslation(); - const { handleError } = useErrorHandler(t); + const { handleError, getErrorMessage } = useErrorHandler(t); + + // dialog mutations surface their API errors inline, not via toast + const [formError, setFormError] = useState(null); + const [assignError, setAssignError] = useState(null); + const [removeError, setRemoveError] = useState(null); + const [toggleError, setToggleError] = useState(null); + + const inlineError = + (set: (message: string | null) => void) => async (err: unknown) => { + console.error(err); + set(await getErrorMessage(err)); + }; + + const clearErrors = () => { + setFormError(null); + setAssignError(null); + setRemoveError(null); + setToggleError(null); + }; const { data: adminsResponse, @@ -89,28 +120,49 @@ export const useOrgAdmins = ( queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] }); }; - const { mutate: inviteAdmin, isPending: isInviting } = useMutation({ - mutationFn: async (payload: OrganizationAdminPayload) => { - const { data } = await assignOrganizationAdmin(payload); + const { mutate: addAdmin, isPending: isAdding } = useMutation({ + mutationFn: async ({ + organizationId, + unitId, + ...person + }: AddAdminInput) => { + // both iam invite endpoints create the user (employee + role + + // SET_PASSWORD OTP in one tx); unitId decides the admin scope + const { data } = unitId + ? await assignUnitAdmin({ unitId, ...person }) + : await assignOrganizationAdmin({ organizationId, ...person }); return data; }, + onMutate: () => setFormError(null), onSuccess: () => { - toast.success(t("orgAdmins.toasts.invited")); + toast.success(t("orgAdmins.toasts.added")); invalidate(); }, - onError: handleError, + onError: inlineError(setFormError), }); const { mutate: assignAdmin, isPending: isAssigning } = useMutation({ - mutationFn: async (payload: { organizationId: string; userId: string }) => { - const { data } = await assignOrgAdminRole(payload); + mutationFn: async ({ + organizationId, + userId, + unitId, + }: { + organizationId: string; + userId: string; + /** set → grant unit-admin of this unit instead of org-admin */ + unitId?: string; + }) => { + const { data } = unitId + ? await assignUnitAdminRole({ unitId, userId }) + : await assignOrgAdminRole({ organizationId, userId }); return data; }, + onMutate: () => setAssignError(null), onSuccess: () => { toast.success(t("orgAdmins.toasts.assigned")); invalidate(); }, - onError: handleError, + onError: inlineError(setAssignError), }); const { mutate: removeAdmin, isPending: isRemoving } = useMutation({ @@ -123,11 +175,12 @@ export const useOrgAdmins = ( }); return data; }, + onMutate: () => setRemoveError(null), onSuccess: () => { toast.success(t("orgAdmins.toasts.removed")); invalidate(); }, - onError: handleError, + onError: inlineError(setRemoveError), }); const { mutate: resendInvite, isPending: isResending } = useMutation({ @@ -148,6 +201,7 @@ export const useOrgAdmins = ( : await deactivateUser(id); return data; }, + onMutate: () => setToggleError(null), onSuccess: (_data, variables) => { toast.success( variables.activate @@ -156,7 +210,7 @@ export const useOrgAdmins = ( ); invalidate(); }, - onError: handleError, + onError: inlineError(setToggleError), }); const { mutate: updateAdminProfile, isPending: isUpdatingProfile } = @@ -171,11 +225,12 @@ export const useOrgAdmins = ( const { data } = await updateProfile(payload, id); return data; }, + onMutate: () => setFormError(null), onSuccess: () => { toast.success(t("orgAdmins.toasts.profileUpdated")); invalidate(); }, - onError: handleError, + onError: inlineError(setFormError), }); return { @@ -183,8 +238,8 @@ export const useOrgAdmins = ( isLoading, isError, refetch, - inviteAdmin, - isInviting, + addAdmin, + isAdding, assignAdmin, isAssigning, removeAdmin, @@ -195,5 +250,10 @@ export const useOrgAdmins = ( isToggling, updateAdminProfile, isUpdatingProfile, + formError, + assignError, + removeError, + toggleError, + clearErrors, }; };