mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
docker exefix: reimplement the admin form
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user