From d47e3ae4f516055f150c2285f73d7c9f9dd2974b Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 23 Jul 2026 11:48:08 +0000 Subject: [PATCH] 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, }; };