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..dcf029204 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,100 @@ "department": "ዲፓርትመንት", "unit": "ክፍል", "notAvailable": "ያልዋቀረ" + }, + "orgAdmins": { + "title": "የድርጅት አስተዳዳሪዎች", + "subtitle": "አስተዳዳሪዎቹን ለማየት እና ለማስተዳደር ድርጅት ይምረጡ።", + "tableName": "የድርጅት አስተዳዳሪዎች", + "selectOrg": "ድርጅት ይምረጡ", + "searchOrgs": "ድርጅቶችን ይፈልጉ...", + "noOrgsFound": "ምንም ድርጅት አልተገኘም።", + "adminsCount": "{{count}} አስተዳዳሪ", + "adminsCount_other": "{{count}} አስተዳዳሪዎች", + "noAdmins": "አስተዳዳሪ የለም", + "activeEmployees": "{{count}} ንቁ ሰራተኞች", + "selectOrgPrompt": "አስተዳዳሪዎቹን ለማስተዳደር ድርጅት ይምረጡ", + "selectOrgPromptHint": "ከላይ ያለውን መምረጫ ተጠቅመው ድርጅት ይፈልጉ እና ይምረጡ።", + "noAdminsHint": "{{name}} እስካሁን አስተዳዳሪ የለውም። አዲስ አስተዳዳሪ ይጋብዙ ወይም ነባር ሰራተኛ ይመድቡ።", + "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": "ስልክ ቁጥር", + "unit": "ክፍል", + "selectUnit": "ክፍል ይምረጡ", + "loadingUnits": "ክፍሎች በመጫን ላይ...", + "noUnit": "ምንም — የድርጅት አስተዳዳሪ", + "unitRequired": "ክፍል ያስፈልጋል" + }, + "edit": { + "title": "የአስተዳዳሪ መገለጫ ያስተካክሉ", + "description": "የዚህን አስተዳዳሪ የመገለጫ ዝርዝሮች ያዘምኑ።", + "submit": "ለውጦችን ያስቀምጡ" + }, + "assign": { + "title": "ነባር ሰራተኛ ይመድቡ", + "description": "የዚህን ድርጅት ሰራተኛ ወደ አስተዳዳሪነት ያሳድጉ።", + "searchUsers": "ሰራተኞችን በስም ወይም በኢሜይል ይፈልጉ...", + "noUsersFound": "ምንም ሰራተኛ አልተገኘም።", + "alreadyAdmin": "አስቀድሞ አስተዳዳሪ ነው", + "submit": "እንደ አስተዳዳሪ ይመድቡ", + "loadError": "ሰራተኞችን መጫን አልተሳካም።", + "users": "ተጠቃሚዎች" + }, + "confirmRemove": { + "title": "አስተዳዳሪ ይወገድ?", + "description": "ይህ የ{{name}}ን የአስተዳዳሪነት ሚና ከ{{org}} ያስወግዳል። የተጠቃሚው መለያ ራሱ ይቀራል።", + "removing": "በማስወገድ ላይ..." + }, + "confirmToggle": { + "activateTitle": "መለያ ይንቃ?", + "deactivateTitle": "መለያ ይቦዝን?", + "description": "ይህ የ{{name}}ን የመለያ ሁኔታ በመላው ስርዓቱ ላይ ይቀይራል፣ ለዚህ ድርጅት ብቻ አይደለም።" + }, + "toasts": { + "assigned": "አስተዳዳሪ በተሳካ ሁኔታ ተመድቧል!", + "removed": "አስተዳዳሪ በተሳካ ሁኔታ ተወግዷል!", + "resent": "ግብዣው እንደገና ተልኳል!", + "activated": "መለያው ነቅቷል!", + "deactivated": "መለያው ቦዝኗል!", + "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 8cf8582b7..81bf4d519 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,100 @@ "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.", + "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", + "unit": "Unit", + "selectUnit": "Select a unit", + "loadingUnits": "Loading units...", + "noUnit": "None — organization admin", + "unitRequired": "Unit is required" + }, + "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", + "loadError": "Failed to load employees.", + "users": "Users" + }, + "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": { + "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!", + "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/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..44211f6c3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AdminFormModal.tsx @@ -0,0 +1,383 @@ +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 { + 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({ + 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")), + 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; + +const EMPTY_VALUES: AdminFormValues = { + name: { en: "", am: "" }, + username: "", + email: "", + phoneNumber: "", + organizationId: "", + unitId: "", +}; + +const RequiredMark = () => *; + +interface AdminFormModalProps { + isOpen: boolean; + onClose: () => void; + /** 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; +} + +/** 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({ + resolver: zodResolver(adminSchema), + 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 ?? "", + }, + username: admin.username ?? "", + email: admin.email ?? "", + phoneNumber: admin.phoneNumber ?? "", + } + : { ...EMPTY_VALUES, organizationId: organizationId ?? "" }, + ); + } + }, [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; + onClose(); + }; + + return ( + !open && handleClose()}> + + + + {isEdit ? t("orgAdmins.edit.title") : t("orgAdmins.add.title")} + + + {isEdit + ? t("orgAdmins.edit.description") + : t("orgAdmins.add.description")} + + + +
+ +
+ ( + + + {t("orgAdmins.form.nameEn")} + + + + + + + + )} + /> + ( + + + {t("orgAdmins.form.nameAm")} + + + + + + + + )} + /> +
+ ( + + + {t("orgAdmins.form.username")} + + + + + + + + )} + /> + ( + + + {t("orgAdmins.form.email")} + + + + + + + + )} + /> + ( + + + {t("orgAdmins.form.phoneNumber")} + + + + + + + + )} + /> + + {!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 new file mode 100644 index 000000000..870d75486 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx @@ -0,0 +1,334 @@ +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"; +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 { 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 of the page's org — shown disabled */ + existingAdminIds: string[]; + /** 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 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) { + const { t } = useTranslation(); + 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 { 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 }, + }); + + 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 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")} + + {t("orgAdmins.assign.description")} + + + +
+ {/* Step 1: organization (page org preselected) */} +
+ + {isLoadingOrgs ? ( +
+ +
+ ) : ( + +
+ {orgs.map((org) => ( + + ))} +
+
+ )} +
+ + {/* 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} +
+ )} + + + + + +
+
+ ); +} 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..d5844a603 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx @@ -0,0 +1,430 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { Building2, Loader2, 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, + isError, + refetch, + addAdmin, + isAdding, + assignAdmin, + isAssigning, + removeAdmin, + isRemoving, + resendInvite, + toggleActive, + isToggling, + updateAdminProfile, + isUpdatingProfile, + formError, + assignError, + removeError, + toggleError, + clearErrors, + } = 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; + const person = { + name: values.name, + username: values.username, + email: values.email, + phoneNumber: values.phoneNumber, + }; + if (editAdmin) { + updateAdminProfile( + { id: editAdmin.id, payload: person }, + { + onSuccess: () => { + setFormOpen(false); + setEditAdmin(null); + }, + }, + ); + } else { + addAdmin( + { + organizationId: values.organizationId, + unitId: values.unitId || undefined, + ...person, + }, + { onSuccess: () => setFormOpen(false) }, + ); + } + }; + + const handleAssign = ( + userId: string, + organizationId: string, + unitId?: string, + ) => { + assignAdmin( + { organizationId, userId, unitId }, + { onSuccess: () => setAssignOpen(false) }, + ); + }; + + const handleRemoveConfirm = () => { + if (!removeTarget || !selectedOrg) return; + const { admin, roleInfo } = removeTarget; + removeAdmin( + // unit-admin-only rows go through the unit endpoint; everything else + // defaults to org removal so a role anomaly never sends unitId: undefined + !roleInfo.isOrgAdmin && roleInfo.unitId + ? { userId: admin.id, unitId: roleInfo.unitId } + : { userId: admin.id, organizationId: selectedOrg.id }, + { onSuccess: () => setRemoveTarget(null) }, + ); + }; + + const handleResend = (admin: OrgAdminUser) => { + if (!admin.email || !admin.phoneNumber) { + toast.error(t("orgAdmins.toasts.missingContact")); + return; + } + const toastId = toast.loading(t("orgAdmins.toasts.resending")); + resendInvite( + { email: admin.email, phoneNumber: admin.phoneNumber }, + { onSettled: () => toast.dismiss(toastId) }, + ); + }; + + 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: handleResend, + 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")} +

+
+ ) : ( + <> + {isError && ( +
+ {t("orgAdmins.loadError")} + +
+ )} + {!isLoading && !isError && adminCount === 0 && ( +
+ {t("orgAdmins.noAdminsHint", { + name: localizedName(selectedOrg.name), + })} +
+ )} + setPageIndex(pageIndex + 1)} + prevFunction={() => setPageIndex(Math.max(pageIndex - 1, 0))} + refresh={refetch} + isLoading={isLoading} + extraToolbar={ +
+ + +
+ } + /> + + )} +
+
+ + {/* Add / Edit modal */} + { + setFormOpen(false); + setEditAdmin(null); + clearErrors(); + }} + admin={editAdmin} + organizationId={selectedOrg?.id} + apiError={formError} + onSubmit={handleFormSubmit} + isSubmitting={isAdding || isUpdatingProfile} + /> + + {/* Assign existing employee modal */} + {selectedOrg && ( + { + setAssignOpen(false); + clearErrors(); + }} + organizationId={selectedOrg.id} + existingAdminIds={existingAdminIds} + apiError={assignError} + onAssign={handleAssign} + isAssigning={isAssigning} + /> + )} + + {/* Remove admin confirm */} + { + if (!open) { + setRemoveTarget(null); + clearErrors(); + } + }} + > + + + + {t("orgAdmins.confirmRemove.title")} + + + {t("orgAdmins.confirmRemove.description", { + name: + localizedName(removeTarget?.admin.name) || + removeTarget?.admin.email, + org: localizedName(selectedOrg?.name), + })} + + + {removeError && ( +
+ {removeError} +
+ )} + + + {t("common.cancel")} + + + {isRemoving + ? t("orgAdmins.confirmRemove.removing") + : t("orgAdmins.actions.remove")} + + +
+
+ + {/* Activate / Deactivate confirm */} + { + if (!open) { + setToggleTarget(null); + clearErrors(); + } + }} + > + + + + {toggleTarget?.isActive + ? t("orgAdmins.confirmToggle.deactivateTitle") + : t("orgAdmins.confirmToggle.activateTitle")} + + + {t("orgAdmins.confirmToggle.description", { + name: + localizedName(toggleTarget?.name) || toggleTarget?.email, + })} + + + {toggleError && ( +
+ {toggleError} +
+ )} + + + {t("common.cancel")} + + + {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 new file mode 100644 index 000000000..cc32d4a5d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx @@ -0,0 +1,163 @@ +import { useEffect, 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, + isError, + refetch, + } = 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 ?? []; + + // 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 ( + + + + + + + + + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+ {t("orgAdmins.pickerError")} + +
+ ) : ( + <> + {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..57c5cddc2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts @@ -0,0 +1,259 @@ +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, +} from "@/super-admin/services/api/organizationAdminService"; +import { + assignOrgAdminRole, + assignUnitAdminRole, + 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 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, 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, + 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: 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.added")); + invalidate(); + }, + onError: inlineError(setFormError), + }); + + const { mutate: assignAdmin, isPending: isAssigning } = useMutation({ + 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: inlineError(setAssignError), + }); + + 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; + }, + onMutate: () => setRemoveError(null), + onSuccess: () => { + toast.success(t("orgAdmins.toasts.removed")); + invalidate(); + }, + onError: inlineError(setRemoveError), + }); + + 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; + }, + onMutate: () => setToggleError(null), + onSuccess: (_data, variables) => { + toast.success( + variables.activate + ? t("orgAdmins.toasts.activated") + : t("orgAdmins.toasts.deactivated"), + ); + invalidate(); + }, + onError: inlineError(setToggleError), + }); + + const { mutate: updateAdminProfile, isPending: isUpdatingProfile } = + useMutation({ + mutationFn: async ({ + id, + payload, + }: { + id: string; + payload: UpdateProfilePayload; + }) => { + const { data } = await updateProfile(payload, id); + return data; + }, + onMutate: () => setFormError(null), + onSuccess: () => { + toast.success(t("orgAdmins.toasts.profileUpdated")); + invalidate(); + }, + onError: inlineError(setFormError), + }); + + return { + adminsResponse, + isLoading, + isError, + refetch, + addAdmin, + isAdding, + assignAdmin, + isAssigning, + removeAdmin, + isRemoving, + resendInvite, + isResending, + toggleActive, + isToggling, + updateAdminProfile, + isUpdatingProfile, + formError, + assignError, + removeError, + toggleError, + clearErrors, + }; +}; 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")); } };