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")); } };