docker exefix: reimplement the admin form

This commit is contained in:
Nathnael
2026-07-23 11:48:08 +00:00
parent cbff529e31
commit d47e3ae4f5
8 changed files with 659 additions and 141 deletions

View File

@@ -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": "ይህ ድርጅት ክፍሎች የሉትም — አስተዳዳሪው እንደ የድርጅት አስተዳዳሪ ይጨመራል።"
}
}
}

View File

@@ -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."
}
}
}

View File

@@ -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). */}
<ApiErrorModal />
<Toaster position="top-right" />
{/* sonner toasts (used across super-admin & user-management)
rendered nowhere without this mount */}
<SonnerToaster position="top-right" richColors />
</AuthProvider>
</BrowserRouter>
</StrictMode>

View File

@@ -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<typeof adminSchema>;
@@ -44,26 +58,37 @@ const EMPTY_VALUES: AdminFormValues = {
username: "",
email: "",
phoneNumber: "",
organizationId: "",
unitId: "",
};
const RequiredMark = () => <span className="text-red-500"> *</span>;
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<AdminFormValues>({
@@ -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({
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>
{isEdit ? t("orgAdmins.edit.title") : t("orgAdmins.invite.title")}
{isEdit ? t("orgAdmins.edit.title") : t("orgAdmins.add.title")}
</DialogTitle>
<DialogDescription>
{isEdit
? t("orgAdmins.edit.description")
: t("orgAdmins.invite.description")}
: t("orgAdmins.add.description")}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="name.en"
render={({ field }) => (
<FormItem>
<FormLabel>{t("orgAdmins.form.nameEn")}</FormLabel>
<FormLabel>
{t("orgAdmins.form.nameEn")}
<RequiredMark />
</FormLabel>
<FormControl>
<Input {...field} />
<Input
placeholder={t("organization.enterEnglishName")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -129,9 +187,15 @@ export default function AdminFormModal({
name="name.am"
render={({ field }) => (
<FormItem>
<FormLabel>{t("orgAdmins.form.nameAm")}</FormLabel>
<FormLabel>
{t("orgAdmins.form.nameAm")}
<RequiredMark />
</FormLabel>
<FormControl>
<Input {...field} />
<Input
placeholder={t("organization.enterAmharicName")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -143,9 +207,15 @@ export default function AdminFormModal({
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{t("orgAdmins.form.username")}</FormLabel>
<FormLabel>
{t("orgAdmins.form.username")}
<RequiredMark />
</FormLabel>
<FormControl>
<Input {...field} />
<Input
placeholder={t("organization.enterUsername")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -156,11 +226,14 @@ export default function AdminFormModal({
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("orgAdmins.form.email")}</FormLabel>
<FormLabel>
{t("orgAdmins.form.email")}
<RequiredMark />
</FormLabel>
<FormControl>
<Input
type="email"
placeholder={t("organization.emailExample", "")}
placeholder={t("organization.emailExample")}
{...field}
/>
</FormControl>
@@ -173,10 +246,14 @@ export default function AdminFormModal({
name="phoneNumber"
render={({ field }) => (
<FormItem>
<FormLabel>{t("orgAdmins.form.phoneNumber")}</FormLabel>
<FormLabel>
{t("orgAdmins.form.phoneNumber")}
<RequiredMark />
</FormLabel>
<FormControl>
<Input
placeholder={t("organization.phoneNumberExample", "")}
type="tel"
placeholder={t("organization.phoneNumberExample")}
{...field}
/>
</FormControl>
@@ -185,6 +262,98 @@ export default function AdminFormModal({
)}
/>
{!isEdit && (
<>
<FormField
control={form.control}
name="organizationId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("organization.organization")}
<RequiredMark />
</FormLabel>
<Select
value={field.value}
onValueChange={(value) => {
field.onChange(value);
form.setValue("unitId", ""); // reset unit when org changes
}}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"organization.selectOrganization",
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{activeOrgs.map((org) => (
<SelectItem key={org.id} value={org.id}>
{localizedName(org.name)}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{(isLoadingUnits || units.length > 0 || !selectedOrgId) && (
<FormField
control={form.control}
name="unitId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("orgAdmins.form.unit")}
<RequiredMark />
</FormLabel>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={!selectedOrgId || isLoadingUnits}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingUnits
? t("orgAdmins.form.loadingUnits")
: t("orgAdmins.form.selectUnit")
}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{units.map((unit: any) => (
<SelectItem key={unit.id} value={unit.id}>
{localizedName(unit.name)}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<p className="text-xs text-muted-foreground">
{!isLoadingUnits && selectedOrgId && units.length === 0
? t("orgAdmins.add.noUnitsOrgAdmin")
: t("orgAdmins.add.inviteNote")}
</p>
</>
)}
{apiError && (
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
{apiError}
</div>
)}
<DialogFooter>
<Button
type="button"
@@ -194,13 +363,16 @@ export default function AdminFormModal({
>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
<Button
type="submit"
disabled={isSubmitting || (!isEdit && isLoadingUnits)}
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isEdit
? t("orgAdmins.edit.submit")
: t("orgAdmins.invite.submit")}
: t("orgAdmins.add.submit")}
</Button>
</DialogFooter>
</form>

View File

@@ -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 (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-[480px]">
<DialogContent className="sm:max-w-[960px]">
<DialogHeader>
<DialogTitle>{t("orgAdmins.assign.title")}</DialogTitle>
<DialogDescription>
@@ -74,69 +131,179 @@ export default function AssignExistingAdminModal({
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("orgAdmins.assign.searchUsers")}
/>
{isLoadingEmployeesByOrg ? (
<div className="flex h-[320px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : (
<ScrollArea className="h-[320px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
<div className="space-y-1">
{filteredEmployees.map((employee: any) => {
const userId = employee.user?.id;
if (!userId) return null;
const isAlreadyAdmin = existingAdminIds.includes(userId);
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{/* Step 1: organization (page org preselected) */}
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("organization.organizations")}
</Label>
{isLoadingOrgs ? (
<div className="flex h-[320px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : (
<ScrollArea className="h-[320px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
<div className="space-y-1">
{orgs.map((org) => (
<button
key={userId}
key={org.id}
type="button"
disabled={isAlreadyAdmin}
onClick={() => setSelectedUserId(userId)}
onClick={() => selectOrg(org.id)}
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
isAlreadyAdmin
? "cursor-not-allowed opacity-50"
: selectedUserId === userId
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
: "text-gray-700 hover:bg-emerald-50 dark:text-gray-300 dark:hover:bg-emerald-900/20",
orgId === org.id
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
: "text-gray-700 hover:bg-emerald-50 dark:text-gray-300 dark:hover:bg-emerald-900/20",
)}
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{localizedName(employee.user?.name) ||
employee.user?.email}
</p>
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
{employee.user?.email}
</p>
</div>
{isAlreadyAdmin ? (
<Badge variant="outline" className="ml-2 shrink-0">
{t("orgAdmins.assign.alreadyAdmin")}
</Badge>
) : (
selectedUserId === userId && (
<Check className="ml-2 h-4 w-4 shrink-0" />
)
<span className="truncate">
{localizedName(org.name)}
</span>
{orgId === org.id && (
<Check className="ml-2 h-4 w-4 shrink-0" />
)}
</button>
);
})}
{filteredEmployees.length === 0 && (
<p className="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">
{t("orgAdmins.assign.noUsersFound")}
</p>
)}
))}
</div>
</ScrollArea>
)}
</div>
{/* Step 2: unit (or none → org admin) */}
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("orgAdmins.form.unit")}
</Label>
{isLoadingUnits ? (
<div className="flex h-[320px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
</ScrollArea>
)}
) : (
<ScrollArea className="h-[320px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
<div className="space-y-1">
<button
type="button"
onClick={() => selectUnit("")}
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
!unitId
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
: "text-gray-700 hover:bg-emerald-50 dark:text-gray-300 dark:hover:bg-emerald-900/20",
)}
>
<span>{t("orgAdmins.form.noUnit")}</span>
{!unitId && <Check className="ml-2 h-4 w-4 shrink-0" />}
</button>
{units.map((unit: any) => (
<button
key={unit.id}
type="button"
onClick={() => selectUnit(unit.id)}
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
unitId === unit.id
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
: "text-gray-700 hover:bg-emerald-50 dark:text-gray-300 dark:hover:bg-emerald-900/20",
)}
>
<span className="truncate">
{localizedName(unit.name)}
</span>
{unitId === unit.id && (
<Check className="ml-2 h-4 w-4 shrink-0" />
)}
</button>
))}
</div>
</ScrollArea>
)}
</div>
{/* Step 3: user */}
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("orgAdmins.assign.users")}
</Label>
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("orgAdmins.assign.searchUsers")}
/>
{isLoadingEmployeesByOrg ? (
<div className="flex h-[272px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : isErrorEmployeesByOrg ? (
<div className="flex h-[272px] flex-col items-center justify-center gap-2 text-sm text-red-600 dark:text-red-400">
{t("orgAdmins.assign.loadError")}
<Button
variant="outline"
size="sm"
onClick={() => refetchEmployeesByOrg()}
>
{t("common.retry")}
</Button>
</div>
) : (
<ScrollArea className="h-[272px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
<div className="space-y-1">
{filteredEmployees.map((employee: any) => {
const userId = employee.user?.id;
if (!userId) return null;
const isAlreadyAdmin = knownAdminIds.includes(userId);
return (
<button
key={userId}
type="button"
disabled={isAlreadyAdmin}
onClick={() => setSelectedUserId(userId)}
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
isAlreadyAdmin
? "cursor-not-allowed opacity-50"
: selectedUserId === userId
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
: "text-gray-700 hover:bg-emerald-50 dark:text-gray-300 dark:hover:bg-emerald-900/20",
)}
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{localizedName(employee.user?.name) ||
employee.user?.email}
</p>
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
{employee.user?.email}
</p>
</div>
{isAlreadyAdmin ? (
<Badge variant="outline" className="ml-2 shrink-0">
{t("orgAdmins.assign.alreadyAdmin")}
</Badge>
) : (
selectedUserId === userId && (
<Check className="ml-2 h-4 w-4 shrink-0" />
)
)}
</button>
);
})}
{filteredEmployees.length === 0 && (
<p className="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">
{t("orgAdmins.assign.noUsersFound")}
</p>
)}
</div>
</ScrollArea>
)}
</div>
</div>
{apiError && (
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
{apiError}
</div>
)}
<DialogFooter>
<Button
type="button"
@@ -148,8 +315,10 @@ export default function AssignExistingAdminModal({
</Button>
<Button
type="button"
disabled={isAssigning || !selectedUserId}
onClick={() => onAssign(selectedUserId)}
disabled={isAssigning || !selectedUserId || !orgId}
onClick={() =>
onAssign(selectedUserId, orgId, unitId || undefined)
}
>
{isAssigning ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Building2, Plus, UserPlus, Users2 } from "lucide-react";
import { toast } from "sonner";
import { Building2, Loader2, Plus, UserPlus, Users2 } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import {
Card,
@@ -57,9 +58,10 @@ export default function OrgAdminsPage() {
const {
adminsResponse,
isLoading,
isError,
refetch,
inviteAdmin,
isInviting,
addAdmin,
isAdding,
assignAdmin,
isAssigning,
removeAdmin,
@@ -69,6 +71,11 @@ export default function OrgAdminsPage() {
isToggling,
updateAdminProfile,
isUpdatingProfile,
formError,
assignError,
removeError,
toggleError,
clearErrors,
} = useOrgAdmins(selectedOrg?.id, {
take: pageSize,
skip: pageIndex * pageSize,
@@ -87,9 +94,15 @@ export default function OrgAdminsPage() {
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: values },
{ id: editAdmin.id, payload: person },
{
onSuccess: () => {
setFormOpen(false);
@@ -98,17 +111,24 @@ export default function OrgAdminsPage() {
},
);
} else {
inviteAdmin(
{ organizationId: selectedOrg.id, ...values },
addAdmin(
{
organizationId: values.organizationId,
unitId: values.unitId || undefined,
...person,
},
{ onSuccess: () => setFormOpen(false) },
);
}
};
const handleAssign = (userId: string) => {
if (!selectedOrg) return;
const handleAssign = (
userId: string,
organizationId: string,
unitId?: string,
) => {
assignAdmin(
{ organizationId: selectedOrg.id, userId },
{ organizationId, userId, unitId },
{ onSuccess: () => setAssignOpen(false) },
);
};
@@ -117,13 +137,27 @@ export default function OrgAdminsPage() {
if (!removeTarget || !selectedOrg) return;
const { admin, roleInfo } = removeTarget;
removeAdmin(
roleInfo.isOrgAdmin
? { userId: admin.id, organizationId: selectedOrg.id }
: { userId: admin.id, unitId: roleInfo.unitId },
// 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(
@@ -144,11 +178,7 @@ export default function OrgAdminsPage() {
setEditAdmin(admin);
setFormOpen(true);
},
onResend: (admin) =>
resendInvite({
email: admin.email ?? "",
phoneNumber: admin.phoneNumber ?? "",
}),
onResend: handleResend,
onToggleActive: setToggleTarget,
onRemove: (admin, roleInfo) => setRemoveTarget({ admin, roleInfo }),
}),
@@ -213,7 +243,15 @@ export default function OrgAdminsPage() {
</div>
) : (
<>
{!isLoading && adminCount === 0 && (
{isError && (
<div className="flex items-center justify-between gap-3 rounded-md border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
{t("orgAdmins.loadError")}
<Button variant="outline" size="sm" onClick={() => refetch()}>
{t("common.retry")}
</Button>
</div>
)}
{!isLoading && !isError && adminCount === 0 && (
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300">
{t("orgAdmins.noAdminsHint", {
name: localizedName(selectedOrg.name),
@@ -250,7 +288,7 @@ export default function OrgAdminsPage() {
}}
>
<Plus className="mr-2 h-4 w-4" />
{t("orgAdmins.inviteAdmin")}
{t("orgAdmins.addAdmin")}
</Button>
</div>
}
@@ -260,25 +298,32 @@ export default function OrgAdminsPage() {
</CardContent>
</Card>
{/* Invite / Edit modal */}
{/* Add / Edit modal */}
<AdminFormModal
isOpen={formOpen}
onClose={() => {
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 && (
<AssignExistingAdminModal
isOpen={assignOpen}
onClose={() => 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 */}
<AlertDialog
open={!!removeTarget}
onOpenChange={(open) => !open && setRemoveTarget(null)}
onOpenChange={(open) => {
if (!open) {
setRemoveTarget(null);
clearErrors();
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
@@ -303,6 +353,11 @@ export default function OrgAdminsPage() {
})}
</AlertDialogDescription>
</AlertDialogHeader>
{removeError && (
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
{removeError}
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={isRemoving}>
{t("common.cancel")}
@@ -323,7 +378,12 @@ export default function OrgAdminsPage() {
{/* Activate / Deactivate confirm */}
<AlertDialog
open={!!toggleTarget}
onOpenChange={(open) => !open && setToggleTarget(null)}
onOpenChange={(open) => {
if (!open) {
setToggleTarget(null);
clearErrors();
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
@@ -339,6 +399,11 @@ export default function OrgAdminsPage() {
})}
</AlertDialogDescription>
</AlertDialogHeader>
{toggleError && (
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-300">
{toggleError}
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={isToggling}>
{t("common.cancel")}
@@ -352,6 +417,7 @@ export default function OrgAdminsPage() {
: "bg-emerald-600 hover:bg-emerald-700 text-white"
}
>
{isToggling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{toggleTarget?.isActive
? t("orgAdmins.actions.deactivate")
: t("orgAdmins.actions.activate")}

View File

@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -89,6 +103,13 @@ export function OrgPicker({ value, onChange }: OrgPickerProps) {
<div className="flex items-center justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
) : isError ? (
<div className="flex flex-col items-center gap-2 py-6 text-sm text-red-600 dark:text-red-400">
{t("orgAdmins.pickerError")}
<Button variant="outline" size="sm" onClick={() => refetch()}>
{t("common.retry")}
</Button>
</div>
) : (
<>
<CommandEmpty>{t("orgAdmins.noOrgsFound")}</CommandEmpty>

View File

@@ -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<string | null>(null);
const [assignError, setAssignError] = useState<string | null>(null);
const [removeError, setRemoveError] = useState<string | null>(null);
const [toggleError, setToggleError] = useState<string | null>(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,
};
};