mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
docker exefix: reimplement the admin form
This commit is contained in:
@@ -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": "ይህ ድርጅት ክፍሎች የሉትም — አስተዳዳሪው እንደ የድርጅት አስተዳዳሪ ይጨመራል።"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user