feat: rebuild the admin pages

This commit is contained in:
Nathnael
2026-07-23 07:40:48 +00:00
parent 919c3a84ff
commit cbff529e31
16 changed files with 1485 additions and 579 deletions

View File

@@ -1,5 +1,6 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator";
import { Type } from "class-transformer";
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength, ValidateNested } from "class-validator";
class CreateOrganizationUserNameDto {
@ApiProperty()
@@ -29,7 +30,8 @@ export class CreateOrganizationUserDto {
phoneNumber?: string;
@ApiProperty({ type: CreateOrganizationUserNameDto })
@IsObject()
@ValidateNested()
@Type(() => CreateOrganizationUserNameDto)
name!: CreateOrganizationUserNameDto;
@ApiProperty({ required: false, default: false })

View File

@@ -2585,7 +2585,6 @@
"archiveDepartment": "የስራ መደብ መጠርያ አርክብ አድርግ",
"deleteDepartment": "የስራ መደብ መጠርያ ሰርዝ",
"deleteConfirm": "የስራ መደብ መጠርያ ይሰርዝ?",
"delete": "ሰርዝ",
"deleteFailed": "የስራ መደብ መጠርያ ሰረዝ ወደ ተሳክቶ",
"cannotDeleteWithEmployees": "ተመድቦ ካለበት ሰራተኞች ጋር የስራ መደብ መጠርያ መሰረዝ አይቻልም",
"reassignEmployeesFirst": "እባክዎ ሁሉንም ሰራተኞች በዚህ ክፍል ውስጥ ዳግም ይሰጧቸው ወይም ያስወግዱ።",
@@ -3117,7 +3116,7 @@
"referenceNumberStyles": "የማጣቀሻ ቁጥር ስታይሎች",
"other": "ሌላ",
"styleCategories": "የስታይል ምድቦች",
"styleEditor": "ስታይል አርታ",
"styleEditor": "ስታይል አርታ",
"livePreview": "የቀጥታ ቅድመ-እይታ",
"fontsHint": "አማራጭ የቅርጸ-ቁምፊ ሪሶርስ ይምረጡ።",
"fontResource": "የቅርጸ-ቁምፊ ፋይል",
@@ -3209,11 +3208,9 @@
"previewLanguage": "የቅድመ-እይታ ቋንቋ",
"amharic": "አማርኛ",
"english": "እንግሊዝኛ",
"styleCategories": "የስታይል ምድቦች",
"categoryHint": "ለማርትዕ ክፍል ይምረጡ",
"expandSidebar": "የጎን ማውጫን ዘርጋ",
"collapseSidebar": "የጎን ማውጫን ጠቅልል",
"styleEditor": "የስታይል አርታዒ",
"headerFooterSelection": "ራስጌ እና ግርጌ",
"noSettings": "ምንም የስታይል ቅንብሮች የሉም",
"visible": "የሚታይ",
@@ -7393,5 +7390,87 @@
"department": "ዲፓርትመንት",
"unit": "ክፍል",
"notAvailable": "ያልዋቀረ"
},
"orgAdmins": {
"title": "የድርጅት አስተዳዳሪዎች",
"subtitle": "አስተዳዳሪዎቹን ለማየት እና ለማስተዳደር ድርጅት ይምረጡ።",
"tableName": "የድርጅት አስተዳዳሪዎች",
"selectOrg": "ድርጅት ይምረጡ",
"searchOrgs": "ድርጅቶችን ይፈልጉ...",
"noOrgsFound": "ምንም ድርጅት አልተገኘም።",
"adminsCount": "{{count}} አስተዳዳሪ",
"adminsCount_other": "{{count}} አስተዳዳሪዎች",
"noAdmins": "አስተዳዳሪ የለም",
"activeEmployees": "{{count}} ንቁ ሰራተኞች",
"selectOrgPrompt": "አስተዳዳሪዎቹን ለማስተዳደር ድርጅት ይምረጡ",
"selectOrgPromptHint": "ከላይ ያለውን መምረጫ ተጠቅመው ድርጅት ይፈልጉ እና ይምረጡ።",
"noAdminsHint": "{{name}} እስካሁን አስተዳዳሪ የለውም። አዲስ አስተዳዳሪ ይጋብዙ ወይም ነባር ሰራተኛ ይመድቡ።",
"inviteAdmin": "አስተዳዳሪ ይጋብዙ",
"assignExisting": "ነባር ሰራተኛ ይመድቡ",
"roleOrgAdmin": "የድርጅት አስተዳዳሪ",
"roleUnitAdmin": "የክፍል አስተዳዳሪ",
"statusInvited": "የተጋበዘ",
"statusActive": "ንቁ",
"statusInactive": "ንቁ ያልሆነ",
"columns": {
"name": "ስም",
"email": "ኢሜይል",
"phone": "ስልክ",
"role": "ሚና",
"status": "ሁኔታ",
"addedOn": "የተጨመረበት ቀን",
"actions": "እርምጃዎች"
},
"actions": {
"edit": "መገለጫ ያስተካክሉ",
"resend": "ግብዣ እንደገና ይላኩ",
"activate": "መለያ ያንቁ",
"deactivate": "መለያ ያቦዝኑ",
"remove": "አስተዳዳሪ ያስወግዱ"
},
"form": {
"nameEn": "ስም (እንግሊዝኛ)",
"nameAm": "ስም (አማርኛ)",
"username": "የተጠቃሚ ስም",
"email": "ኢሜይል",
"phoneNumber": "ስልክ ቁጥር"
},
"invite": {
"title": "አዲስ አስተዳዳሪ ይጋብዙ",
"description": "ተጋባዡ የይለፍ ቃል ለማዘጋጀት እና መለያውን ለማንቃት አገናኝ በSMS ይደርሰዋል።",
"submit": "ግብዣ ይላኩ"
},
"edit": {
"title": "የአስተዳዳሪ መገለጫ ያስተካክሉ",
"description": "የዚህን አስተዳዳሪ የመገለጫ ዝርዝሮች ያዘምኑ።",
"submit": "ለውጦችን ያስቀምጡ"
},
"assign": {
"title": "ነባር ሰራተኛ ይመድቡ",
"description": "የዚህን ድርጅት ሰራተኛ ወደ አስተዳዳሪነት ያሳድጉ።",
"searchUsers": "ሰራተኞችን በስም ወይም በኢሜይል ይፈልጉ...",
"noUsersFound": "ምንም ሰራተኛ አልተገኘም።",
"alreadyAdmin": "አስቀድሞ አስተዳዳሪ ነው",
"submit": "እንደ አስተዳዳሪ ይመድቡ"
},
"confirmRemove": {
"title": "አስተዳዳሪ ይወገድ?",
"description": "ይህ የ{{name}}ን የአስተዳዳሪነት ሚና ከ{{org}} ያስወግዳል። የተጠቃሚው መለያ ራሱ ይቀራል።",
"removing": "በማስወገድ ላይ..."
},
"confirmToggle": {
"activateTitle": "መለያ ይንቃ?",
"deactivateTitle": "መለያ ይቦዝን?",
"description": "ይህ የ{{name}}ን የመለያ ሁኔታ በመላው ስርዓቱ ላይ ይቀይራል፣ ለዚህ ድርጅት ብቻ አይደለም።"
},
"toasts": {
"invited": "ግብዣው በተሳካ ሁኔታ ተልኳል!",
"assigned": "አስተዳዳሪ በተሳካ ሁኔታ ተመድቧል!",
"removed": "አስተዳዳሪ በተሳካ ሁኔታ ተወግዷል!",
"resent": "ግብዣው እንደገና ተልኳል!",
"activated": "መለያው ነቅቷል!",
"deactivated": "መለያው ቦዝኗል!",
"profileUpdated": "መገለጫው ተዘምኗል!"
}
}
}

View File

@@ -7391,5 +7391,87 @@
"department": "Department",
"unit": "Unit",
"notAvailable": "Not Available"
},
"orgAdmins": {
"title": "Organization Admins",
"subtitle": "Pick an organization to view and manage its administrators.",
"tableName": "Organization Admins",
"selectOrg": "Select an organization",
"searchOrgs": "Search organizations...",
"noOrgsFound": "No organizations found.",
"adminsCount": "{{count}} admin",
"adminsCount_other": "{{count}} admins",
"noAdmins": "No admins",
"activeEmployees": "{{count}} active employees",
"selectOrgPrompt": "Select an organization to manage its admins",
"selectOrgPromptHint": "Use the selector above to search and pick an organization.",
"noAdminsHint": "{{name}} has no administrators yet. Invite a new admin or assign an existing employee.",
"inviteAdmin": "Invite Admin",
"assignExisting": "Assign Existing",
"roleOrgAdmin": "Org Admin",
"roleUnitAdmin": "Unit Admin",
"statusInvited": "Invited",
"statusActive": "Active",
"statusInactive": "Inactive",
"columns": {
"name": "Name",
"email": "Email",
"phone": "Phone",
"role": "Role",
"status": "Status",
"addedOn": "Added On",
"actions": "Actions"
},
"actions": {
"edit": "Edit Profile",
"resend": "Resend Invite",
"activate": "Activate Account",
"deactivate": "Deactivate Account",
"remove": "Remove Admin"
},
"form": {
"nameEn": "Name (English)",
"nameAm": "Name (Amharic)",
"username": "Username",
"email": "Email",
"phoneNumber": "Phone Number"
},
"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"
},
"edit": {
"title": "Edit Admin Profile",
"description": "Update this administrator's profile details.",
"submit": "Save Changes"
},
"assign": {
"title": "Assign Existing Employee",
"description": "Promote an employee of this organization to administrator.",
"searchUsers": "Search employees by name or email...",
"noUsersFound": "No employees found.",
"alreadyAdmin": "Already admin",
"submit": "Assign as Admin"
},
"confirmRemove": {
"title": "Remove Admin?",
"description": "This removes the admin role of {{name}} for {{org}}. The user account itself is kept.",
"removing": "Removing..."
},
"confirmToggle": {
"activateTitle": "Activate Account?",
"deactivateTitle": "Deactivate Account?",
"description": "This changes the account status of {{name}} across the whole platform, not just for this organization."
},
"toasts": {
"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!"
}
}
}

View File

@@ -67,7 +67,8 @@ const DashboardPage = () => {
refetch();
refetchAdmins();
}}
className="bg-primary hover:bg-primary/90 text-primary-foreground">
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("organization.retry")}
</Button>
@@ -140,7 +141,8 @@ const DashboardPage = () => {
<Link to="/organizations">
<Button
variant="link"
className="text-primary dark:text-primary-400 text-sm px-0">
className="text-primary dark:text-primary-400 text-sm px-0"
>
{t("organization.viewMore")}
</Button>
</Link>

View File

@@ -1,7 +1,7 @@
import OrganizationsAdmins from "@/super-admin/components/organizationAdmins/OrganizationAdmins";
import OrgAdminsPage from "@/super-admin/components/org-admins/OrgAdminsPage";
const OrganizationAdminsPage = () => {
return <OrganizationsAdmins />;
return <OrgAdminsPage />;
};
export default OrganizationAdminsPage;

View File

@@ -0,0 +1,211 @@
import { useEffect } from "react";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "i18next";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/common/ui/form";
import { Input } from "@/shared/common/ui/input";
import { OrgAdminUser } from "@/super-admin/hooks/useOrgAdmins";
const adminSchema = z.object({
name: z.object({
en: z.string().min(1, t("organization.englishNameRequired")),
am: z.string().min(1, t("organization.amharicNameRequired")),
}),
username: z.string().min(3, t("organization.usernameMinLength")),
email: z.string().email(t("organization.invalidEmail")),
phoneNumber: z
.string()
.regex(/^(\+251|0)?9\d{8}$/, t("organization.invalidPhoneNumber")),
});
export type AdminFormValues = z.infer<typeof adminSchema>;
const EMPTY_VALUES: AdminFormValues = {
name: { en: "", am: "" },
username: "",
email: "",
phoneNumber: "",
};
interface AdminFormModalProps {
isOpen: boolean;
onClose: () => void;
/** null → invite a new admin; set → edit this admin's profile */
admin: OrgAdminUser | null;
onSubmit: (values: AdminFormValues) => void;
isSubmitting: boolean;
}
/** Invite-new-admin / edit-admin-profile modal (same fields, one form). */
export default function AdminFormModal({
isOpen,
onClose,
admin,
onSubmit,
isSubmitting,
}: AdminFormModalProps) {
const { t } = useTranslation();
const isEdit = !!admin;
const form = useForm<AdminFormValues>({
resolver: zodResolver(adminSchema),
defaultValues: EMPTY_VALUES,
});
useEffect(() => {
if (isOpen) {
form.reset(
admin
? {
name: {
en: admin.name?.en ?? "",
am: admin.name?.am ?? "",
},
username: admin.username ?? "",
email: admin.email ?? "",
phoneNumber: admin.phoneNumber ?? "",
}
: EMPTY_VALUES,
);
}
}, [isOpen, admin, form]);
const handleClose = () => {
if (isSubmitting) return;
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>
{isEdit ? t("orgAdmins.edit.title") : t("orgAdmins.invite.title")}
</DialogTitle>
<DialogDescription>
{isEdit
? t("orgAdmins.edit.description")
: t("orgAdmins.invite.description")}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} 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>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name.am"
render={({ field }) => (
<FormItem>
<FormLabel>{t("orgAdmins.form.nameAm")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{t("orgAdmins.form.username")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("orgAdmins.form.email")}</FormLabel>
<FormControl>
<Input
type="email"
placeholder={t("organization.emailExample", "")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phoneNumber"
render={({ field }) => (
<FormItem>
<FormLabel>{t("orgAdmins.form.phoneNumber")}</FormLabel>
<FormControl>
<Input
placeholder={t("organization.phoneNumberExample", "")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isSubmitting}
>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isEdit
? t("orgAdmins.edit.submit")
: t("orgAdmins.invite.submit")}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,165 @@
import { useMemo, useState } from "react";
import { Check, Loader2, UserPlus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/shared/common/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { Input } from "@/shared/common/ui/input";
import { 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";
interface AssignExistingAdminModalProps {
isOpen: boolean;
onClose: () => void;
organizationId: string;
/** user ids that are already admins — shown disabled */
existingAdminIds: string[];
onAssign: (userId: string) => void;
isAssigning: boolean;
}
/** Promote an existing employee of the selected organization to org admin. */
export default function AssignExistingAdminModal({
isOpen,
onClose,
organizationId,
existingAdminIds,
onAssign,
isAssigning,
}: AssignExistingAdminModalProps) {
const { t } = useTranslation();
const localizedName = useLocalizedName();
const [selectedUserId, setSelectedUserId] = useState("");
const [search, setSearch] = useState("");
const { employeesResponseByOrg, isLoadingEmployeesByOrg } = useEmployees({
organizationId: isOpen ? organizationId : undefined,
params: { take: 3000, skip: 0 },
});
const filteredEmployees = useMemo(() => {
const query = search.trim().toLowerCase();
const employees = employeesResponseByOrg?.items ?? [];
if (!query) return employees;
return employees.filter((employee: any) => {
const name = localizedName(employee.user?.name).toLowerCase();
const email = employee.user?.email?.toLowerCase() ?? "";
return name.includes(query) || email.includes(query);
});
}, [employeesResponseByOrg, localizedName, search]);
const handleClose = () => {
if (isAssigning) return;
setSelectedUserId("");
setSearch("");
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>{t("orgAdmins.assign.title")}</DialogTitle>
<DialogDescription>
{t("orgAdmins.assign.description")}
</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 (
<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>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isAssigning}
>
{t("common.cancel")}
</Button>
<Button
type="button"
disabled={isAssigning || !selectedUserId}
onClick={() => onAssign(selectedUserId)}
>
{isAssigning ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<UserPlus className="mr-2 h-4 w-4" />
)}
{t("orgAdmins.assign.submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,228 @@
import { ColumnDef } from "@tanstack/react-table";
import {
ArrowUpDown,
MoreHorizontal,
Pencil,
Send,
Trash2,
UserCheck,
UserX,
} from "lucide-react";
import { t } from "i18next";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/common/ui/dropdown-menu";
import { OrgAdminUser } from "@/super-admin/hooks/useOrgAdmins";
export const ORG_ADMIN_ROLE_KEY = "organization_admin";
export const UNIT_ADMIN_ROLE_KEY = "unit_admin";
export interface AdminRoleInfo {
isOrgAdmin: boolean;
isUnitAdmin: boolean;
unitId?: string;
}
/**
* all-admins/:id returns users who are org admins of the org OR unit admins of
* one of its units; userRoles carries every role of the user, so match the org
* explicitly for the org-admin grant.
*/
// ponytail: unit relation isn't loaded, so a unit_admin grant from another org
// can't be told apart — acceptable, the server only returns admins of this org.
export function getAdminRoleInfo(
admin: OrgAdminUser,
selectedOrgId: string,
): AdminRoleInfo {
const roles = admin.userRoles ?? [];
const isOrgAdmin = roles.some(
(r) =>
r.role?.key === ORG_ADMIN_ROLE_KEY &&
r.organizationId === selectedOrgId,
);
const unitRole = roles.find(
(r) => r.role?.key === UNIT_ADMIN_ROLE_KEY && r.unitId,
);
return {
isOrgAdmin,
isUnitAdmin: !!unitRole,
unitId: unitRole?.unitId ?? undefined,
};
}
interface ColumnCallbacks {
selectedOrgId: string;
localizedName: (name?: { am?: string; en?: string }) => string;
onEdit: (admin: OrgAdminUser) => void;
onResend: (admin: OrgAdminUser) => void;
onToggleActive: (admin: OrgAdminUser) => void;
onRemove: (admin: OrgAdminUser, roleInfo: AdminRoleInfo) => void;
}
export function getOrgAdminsColumnDefn({
selectedOrgId,
localizedName,
onEdit,
onResend,
onToggleActive,
onRemove,
}: ColumnCallbacks): ColumnDef<OrgAdminUser>[] {
return [
{
id: "name",
accessorFn: (row) => localizedName(row.name),
header: ({ column }) => (
<Button
variant="ghost"
className="px-0 hover:bg-transparent"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{t("orgAdmins.columns.name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<div>
<p className="font-medium text-slate-800 dark:text-slate-100">
{localizedName(row.original.name) || "—"}
</p>
<p className="text-xs text-muted-foreground">
{row.original.username}
</p>
</div>
),
},
{
id: "email",
accessorFn: (row) => row.email ?? "",
header: () => t("orgAdmins.columns.email"),
cell: ({ row }) => (
<span className="text-sm">{row.original.email || "—"}</span>
),
},
{
id: "phoneNumber",
accessorFn: (row) => row.phoneNumber ?? "",
header: () => t("orgAdmins.columns.phone"),
cell: ({ row }) => (
<span className="text-sm">{row.original.phoneNumber || "—"}</span>
),
},
{
id: "role",
header: () => t("orgAdmins.columns.role"),
cell: ({ row }) => {
const info = getAdminRoleInfo(row.original, selectedOrgId);
return (
<div className="flex flex-wrap gap-1">
{info.isOrgAdmin && (
<Badge className="bg-indigo-100 text-indigo-700 hover:bg-indigo-100 dark:bg-indigo-950 dark:text-indigo-300">
{t("orgAdmins.roleOrgAdmin")}
</Badge>
)}
{info.isUnitAdmin && (
<Badge className="bg-sky-100 text-sky-700 hover:bg-sky-100 dark:bg-sky-950 dark:text-sky-300">
{t("orgAdmins.roleUnitAdmin")}
</Badge>
)}
</div>
);
},
},
{
id: "status",
accessorFn: (row) =>
!row.hasSetPassword
? "invited"
: row.isActive
? "active"
: "inactive",
header: () => t("orgAdmins.columns.status"),
cell: ({ row }) => {
const admin = row.original;
if (!admin.hasSetPassword) {
return (
<Badge className="bg-amber-100 text-amber-700 hover:bg-amber-100 dark:bg-amber-950 dark:text-amber-300">
{t("orgAdmins.statusInvited")}
</Badge>
);
}
return admin.isActive ? (
<Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-950 dark:text-emerald-300">
{t("orgAdmins.statusActive")}
</Badge>
) : (
<Badge className="bg-slate-200 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300">
{t("orgAdmins.statusInactive")}
</Badge>
);
},
},
{
id: "createdAt",
accessorFn: (row) => row.createdAt ?? "",
header: () => t("orgAdmins.columns.addedOn"),
cell: ({ row }) =>
row.original.createdAt
? new Date(row.original.createdAt).toLocaleDateString()
: "—",
},
{
id: "actions",
header: () => t("orgAdmins.columns.actions"),
enableHiding: false,
cell: ({ row }) => {
const admin = row.original;
const roleInfo = getAdminRoleInfo(admin, selectedOrgId);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(admin)}>
<Pencil className="mr-2 h-4 w-4" />
{t("orgAdmins.actions.edit")}
</DropdownMenuItem>
{!admin.hasSetPassword && (
<DropdownMenuItem onClick={() => onResend(admin)}>
<Send className="mr-2 h-4 w-4" />
{t("orgAdmins.actions.resend")}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onToggleActive(admin)}>
{admin.isActive ? (
<>
<UserX className="mr-2 h-4 w-4" />
{t("orgAdmins.actions.deactivate")}
</>
) : (
<>
<UserCheck className="mr-2 h-4 w-4" />
{t("orgAdmins.actions.activate")}
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600 focus:text-red-600"
onClick={() => onRemove(admin, roleInfo)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("orgAdmins.actions.remove")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
}

View File

@@ -0,0 +1,364 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Building2, Plus, UserPlus, Users2 } from "lucide-react";
import { Button } from "@/shared/common/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Badge } from "@/shared/common/ui/badge";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { useLocalizedName } from "@/shared/common/localizedName";
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
import {
OrgAdminUser,
useOrgAdmins,
} from "@/super-admin/hooks/useOrgAdmins";
import { OrgPicker } from "./OrgPicker";
import {
AdminRoleInfo,
getOrgAdminsColumnDefn,
} from "./OrgAdminsColumnDefn";
import AdminFormModal, { AdminFormValues } from "./AdminFormModal";
import AssignExistingAdminModal from "./AssignExistingAdminModal";
interface RemoveTarget {
admin: OrgAdminUser;
roleInfo: AdminRoleInfo;
}
export default function OrgAdminsPage() {
const { t } = useTranslation();
const localizedName = useLocalizedName();
const [selectedOrg, setSelectedOrg] = useState<OrganizationDto | null>(null);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
// modals & confirms
const [formOpen, setFormOpen] = useState(false);
const [editAdmin, setEditAdmin] = useState<OrgAdminUser | null>(null);
const [assignOpen, setAssignOpen] = useState(false);
const [removeTarget, setRemoveTarget] = useState<RemoveTarget | null>(null);
const [toggleTarget, setToggleTarget] = useState<OrgAdminUser | null>(null);
const {
adminsResponse,
isLoading,
refetch,
inviteAdmin,
isInviting,
assignAdmin,
isAssigning,
removeAdmin,
isRemoving,
resendInvite,
toggleActive,
isToggling,
updateAdminProfile,
isUpdatingProfile,
} = useOrgAdmins(selectedOrg?.id, {
take: pageSize,
skip: pageIndex * pageSize,
});
useEffect(() => {
setPageIndex(0);
}, [selectedOrg?.id, pageSize]);
const admins = adminsResponse?.items ?? [];
const adminCount = adminsResponse?.count ?? 0;
const existingAdminIds = useMemo(
() => admins.map((admin) => admin.id),
[admins],
);
const handleFormSubmit = (values: AdminFormValues) => {
if (!selectedOrg) return;
if (editAdmin) {
updateAdminProfile(
{ id: editAdmin.id, payload: values },
{
onSuccess: () => {
setFormOpen(false);
setEditAdmin(null);
},
},
);
} else {
inviteAdmin(
{ organizationId: selectedOrg.id, ...values },
{ onSuccess: () => setFormOpen(false) },
);
}
};
const handleAssign = (userId: string) => {
if (!selectedOrg) return;
assignAdmin(
{ organizationId: selectedOrg.id, userId },
{ onSuccess: () => setAssignOpen(false) },
);
};
const handleRemoveConfirm = () => {
if (!removeTarget || !selectedOrg) return;
const { admin, roleInfo } = removeTarget;
removeAdmin(
roleInfo.isOrgAdmin
? { userId: admin.id, organizationId: selectedOrg.id }
: { userId: admin.id, unitId: roleInfo.unitId },
{ onSuccess: () => setRemoveTarget(null) },
);
};
const handleToggleConfirm = () => {
if (!toggleTarget) return;
toggleActive(
{ id: toggleTarget.id, activate: !toggleTarget.isActive },
{ onSuccess: () => setToggleTarget(null) },
);
};
const columns = useMemo(
() =>
getOrgAdminsColumnDefn({
selectedOrgId: selectedOrg?.id ?? "",
localizedName: localizedName as (name?: {
am?: string;
en?: string;
}) => string,
onEdit: (admin) => {
setEditAdmin(admin);
setFormOpen(true);
},
onResend: (admin) =>
resendInvite({
email: admin.email ?? "",
phoneNumber: admin.phoneNumber ?? "",
}),
onToggleActive: setToggleTarget,
onRemove: (admin, roleInfo) => setRemoveTarget({ admin, roleInfo }),
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedOrg?.id],
);
return (
<div className="p-6 space-y-6">
<Card className="shadow-none border-none bg-transparent px-0">
<CardHeader className="px-0 space-y-1">
<CardTitle className="text-2xl font-bold text-slate-800 dark:text-slate-100">
{t("orgAdmins.title")}
</CardTitle>
<p className="text-sm text-muted-foreground">
{t("orgAdmins.subtitle")}
</p>
</CardHeader>
<CardContent className="px-0 space-y-4">
{/* Org selector + summary */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<OrgPicker value={selectedOrg} onChange={setSelectedOrg} />
{selectedOrg && (
<div className="flex flex-wrap items-center gap-2">
<Badge
variant="outline"
className="gap-1 border-indigo-300 bg-indigo-50 text-indigo-700 dark:border-indigo-800 dark:bg-indigo-950 dark:text-indigo-300"
>
<Users2 className="h-3.5 w-3.5" />
{t("orgAdmins.adminsCount", { count: adminCount })}
</Badge>
{selectedOrg.activeEmployeeCount !== undefined && (
<Badge variant="outline" className="gap-1">
{t("orgAdmins.activeEmployees", {
count: selectedOrg.activeEmployeeCount,
})}
</Badge>
)}
<Badge
variant="outline"
className={
selectedOrg.status === "Active"
? "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
: "border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
}
>
{selectedOrg.status}
</Badge>
</div>
)}
</div>
{!selectedOrg ? (
<div className="flex h-64 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-gray-300 dark:border-gray-700">
<Building2 className="h-10 w-10 text-muted-foreground" />
<p className="font-medium text-slate-700 dark:text-slate-200">
{t("orgAdmins.selectOrgPrompt")}
</p>
<p className="text-sm text-muted-foreground">
{t("orgAdmins.selectOrgPromptHint")}
</p>
</div>
) : (
<>
{!isLoading && 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),
})}
</div>
)}
<AdvancedTable
columns={columns}
data={admins}
tableName={t("orgAdmins.tableName")}
toolBarPosition="right"
itemCount={adminCount}
pageIndex={pageIndex}
pageSize={pageSize}
onPageChange={setPageIndex}
onPageSizeChange={setPageSize}
nextFunction={() => setPageIndex(pageIndex + 1)}
prevFunction={() => setPageIndex(Math.max(pageIndex - 1, 0))}
refresh={refetch}
isLoading={isLoading}
extraToolbar={
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setAssignOpen(true)}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("orgAdmins.assignExisting")}
</Button>
<Button
onClick={() => {
setEditAdmin(null);
setFormOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
{t("orgAdmins.inviteAdmin")}
</Button>
</div>
}
/>
</>
)}
</CardContent>
</Card>
{/* Invite / Edit modal */}
<AdminFormModal
isOpen={formOpen}
onClose={() => {
setFormOpen(false);
setEditAdmin(null);
}}
admin={editAdmin}
onSubmit={handleFormSubmit}
isSubmitting={isInviting || isUpdatingProfile}
/>
{/* Assign existing employee modal */}
{selectedOrg && (
<AssignExistingAdminModal
isOpen={assignOpen}
onClose={() => setAssignOpen(false)}
organizationId={selectedOrg.id}
existingAdminIds={existingAdminIds}
onAssign={handleAssign}
isAssigning={isAssigning}
/>
)}
{/* Remove admin confirm */}
<AlertDialog
open={!!removeTarget}
onOpenChange={(open) => !open && setRemoveTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-lg font-bold text-red-600">
{t("orgAdmins.confirmRemove.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("orgAdmins.confirmRemove.description", {
name:
localizedName(removeTarget?.admin.name) ||
removeTarget?.admin.email,
org: localizedName(selectedOrg?.name),
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRemoving}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
disabled={isRemoving}
onClick={handleRemoveConfirm}
className="bg-red-600 hover:bg-red-700 text-white"
>
{isRemoving
? t("orgAdmins.confirmRemove.removing")
: t("orgAdmins.actions.remove")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Activate / Deactivate confirm */}
<AlertDialog
open={!!toggleTarget}
onOpenChange={(open) => !open && setToggleTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-lg font-bold">
{toggleTarget?.isActive
? t("orgAdmins.confirmToggle.deactivateTitle")
: t("orgAdmins.confirmToggle.activateTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("orgAdmins.confirmToggle.description", {
name:
localizedName(toggleTarget?.name) || toggleTarget?.email,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isToggling}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
disabled={isToggling}
onClick={handleToggleConfirm}
className={
toggleTarget?.isActive
? "bg-amber-600 hover:bg-amber-700 text-white"
: "bg-emerald-600 hover:bg-emerald-700 text-white"
}
>
{toggleTarget?.isActive
? t("orgAdmins.actions.deactivate")
: t("orgAdmins.actions.activate")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useDebouncedValue } from "@mantine/hooks";
import { Building2, Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/common/ui/popover";
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/shared/common/ui/command";
import { cn } from "@/super-admin/lib/utils";
import { useLocalizedName } from "@/shared/common/localizedName";
import { getOrganizationsWithAdminFlag } from "@/shared/services/organizationsService";
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
import { ORG_PICKER_KEY } from "@/super-admin/hooks/useOrgAdmins";
interface OrgPickerProps {
value: OrganizationDto | null;
onChange: (org: OrganizationDto) => void;
}
/** Searchable organization combobox — server-side name search, shows admin counts. */
export function OrgPicker({ value, onChange }: OrgPickerProps) {
const { t } = useTranslation();
const localizedName = useLocalizedName();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const { data: orgsResponse, isLoading } = useQuery({
queryKey: [ORG_PICKER_KEY, debouncedSearch],
queryFn: async () => {
const { data } = await getOrganizationsWithAdminFlag({
take: 50,
skip: 0,
name: debouncedSearch || undefined,
});
return {
count: (data?.count ?? 0) as number,
items: (data?.items ?? []) as OrganizationDto[],
};
},
staleTime: 5 * 60 * 1000,
retry: false,
});
const orgs = orgsResponse?.items ?? [];
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full sm:w-[360px] justify-between font-normal"
>
<span className="flex items-center gap-2 truncate">
<Building2 className="h-4 w-4 shrink-0 text-muted-foreground" />
{value ? (
<span className="truncate">{localizedName(value.name)}</span>
) : (
<span className="text-muted-foreground">
{t("orgAdmins.selectOrg")}
</span>
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[360px] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder={t("orgAdmins.searchOrgs")}
value={search}
onValueChange={setSearch}
/>
<CommandList>
{isLoading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
) : (
<>
<CommandEmpty>{t("orgAdmins.noOrgsFound")}</CommandEmpty>
{orgs.map((org) => (
<CommandItem
key={org.id}
value={org.id}
onSelect={() => {
onChange(org);
setOpen(false);
}}
className="flex items-center justify-between gap-2"
>
<span className="flex items-center gap-2 truncate">
<Check
className={cn(
"h-4 w-4 shrink-0",
value?.id === org.id ? "opacity-100" : "opacity-0",
)}
/>
<span className="truncate">
{localizedName(org.name)}
</span>
</span>
{(org.adminsCount ?? 0) > 0 ? (
<Badge
variant="outline"
className="shrink-0 border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
>
{t("orgAdmins.adminsCount", {
count: org.adminsCount ?? 0,
})}
</Badge>
) : (
<Badge
variant="outline"
className="shrink-0 border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300"
>
{t("orgAdmins.noAdmins")}
</Badge>
)}
</CommandItem>
))}
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,277 +0,0 @@
import { FormEvent, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Check, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/shared/common/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { ScrollArea } from "@/shared/common/ui/scroll-area";
import { useLocalizedName } from "@/shared/common/localizedName";
import { cn } from "@/super-admin/lib/utils";
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
import {
assignOrgAdminRole,
RemoveOrAssignOrgAdminPayload,
} from "@/super-admin/services/api/userRoleService";
import { useEmployees } from "@/user-management/hooks/useEmployees";
interface AssignOrgAdminDialogProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function AssignOrgAdminDialog({
isOpen,
onClose,
onSuccess,
}: AssignOrgAdminDialogProps) {
const queryClient = useQueryClient();
const { t } = useTranslation();
const localizedName = useLocalizedName();
const [selectedOrg, setSelectedOrg] = useState("");
const [selectedUser, setSelectedUser] = useState("");
const [search, setSearch] = useState("");
const [isAssigning, setIsAssigning] = useState(false);
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
"Org",
{ take: 300 }
);
const {
employeesResponseByOrg,
isLoadingEmployeesByOrg: isLoadingEmployees,
} = useEmployees({
organizationId: selectedOrg || undefined,
params: { take: 3000, skip: 0 },
});
const filteredEmployees = useMemo(() => {
const query = search.trim().toLowerCase();
const employees = employeesResponseByOrg?.items ?? [];
if (!query) return employees;
return employees.filter((employee) => {
const name = localizedName(employee.user.name).toLowerCase();
const email = employee.user.email?.toLowerCase() ?? "";
return name.includes(query) || email.includes(query);
});
}, [employeesResponseByOrg, localizedName, search]);
const resetForm = () => {
setSelectedOrg("");
setSelectedUser("");
setSearch("");
};
const handleClose = () => {
if (isAssigning) return;
resetForm();
onClose();
};
const handleOrganizationChange = (organizationId: string) => {
setSelectedOrg(organizationId);
setSelectedUser("");
setSearch("");
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedOrg || !selectedUser) {
toast.error(t("organization.allFieldsRequired", "All fields are required"));
return;
}
const payload: RemoveOrAssignOrgAdminPayload = {
organizationId: selectedOrg,
userId: selectedUser,
};
setIsAssigning(true);
try {
await assignOrgAdminRole(payload);
await queryClient.invalidateQueries({
queryKey: ["organizationAdmins"],
});
resetForm();
onClose();
onSuccess();
} catch (error: any) {
toast.error(t("organization.userAssignFailed"), {
description: error?.response?.data?.message,
});
} finally {
setIsAssigning(false);
}
};
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) handleClose();
}}
>
<DialogContent className="sm:max-w-[750px]">
<DialogHeader>
<DialogTitle>
{t(
"organization.assignAdminToOrganization",
"Assign admin to organization"
)}
</DialogTitle>
<DialogDescription>
{t(
"organization.assignOrgAdminInstructions",
"Select an organization and a user to assign as its administrator."
)}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("organization.organizations")}{" "}
<span className="text-red-500">*</span>
</Label>
{isLoadingOrgs ? (
<div className="flex h-[350px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : (
<ScrollArea className="h-[350px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
<div className="space-y-1">
{organizationsResponse?.items?.map((organization) => (
<button
key={organization.id}
type="button"
onClick={() =>
handleOrganizationChange(organization.id)
}
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
selectedOrg === organization.id
? "bg-green-200 text-green-800 dark:bg-green-900/40 dark:text-green-300"
: "text-gray-700 hover:bg-green-100 dark:text-gray-300 dark:hover:bg-green-900/30"
)}
>
<span>{localizedName(organization.name)}</span>
{selectedOrg === organization.id && (
<Check className="h-4 w-4" />
)}
</button>
))}
</div>
</ScrollArea>
)}
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("organization.users")}{" "}
<span className="text-red-500">*</span>
</Label>
{!selectedOrg ? (
<div className="flex h-[350px] items-center justify-center rounded-md border border-gray-200 p-4 text-gray-500 dark:border-gray-700 dark:text-gray-400">
<p className="text-center text-sm">
{t("organization.selectOrganizationFirst")}
</p>
</div>
) : isLoadingEmployees ? (
<div className="flex h-[350px] items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : (
<div className="space-y-2">
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t(
"organization.searchOrganizationUsers",
"Search organization users"
)}
/>
<ScrollArea className="h-[308px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
<div className="space-y-1">
{filteredEmployees.map((employee) => (
<button
key={employee.user.id}
type="button"
onClick={() => setSelectedUser(employee.user.id)}
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
selectedUser === employee.user.id
? "bg-purple-200 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300"
: "text-gray-700 hover:bg-purple-100 dark:text-gray-300 dark:hover:bg-purple-900/30"
)}
>
<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>
{selectedUser === employee.user.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("organization.noUsersFound")}
</p>
)}
</div>
</ScrollArea>
</div>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isAssigning}
>
{t("common.Cancel")}
</Button>
<Button
type="submit"
disabled={isAssigning || !selectedOrg || !selectedUser}
className="bg-green-600 hover:bg-green-700"
>
{isAssigning && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isAssigning
? t("organization.assigning")
: t("organization.assignUser")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,134 +0,0 @@
import { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Link } from "react-router-dom";
import { Plus, Loader2, UserPlus } from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../shared/common/ui/card";
import { toast } from "sonner";
import { AdvancedTable } from "../../../shared/common/ui/table/AdvancedTable";
import { OrganizationAdminsColumnDefn } from "./OrganizationAdminsColumnDefn";
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
import { AssignOrgAdminDialog } from "./AssignOrgAdminDialog";
import { useTranslation } from "react-i18next";
import { useLocalizedName } from "@/shared/common/localizedName";
export default function OrganizationAdmins() {
const [pageIndex, setPageIndex] = useState(0); // starts at 0
const pageSize = 10;
const { t } = useTranslation();
const localizedName = useLocalizedName();
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const { organizationsAdminsResponse, isLoading, isError, refetch } =
useOrganizations("Admin", {
take: pageSize,
skip: pageIndex * pageSize,
orderBy: "createdAt",
order: "createdAt:Desc",
name: searchTerm || undefined,
});
const handlePageChange = (newPage: number) => {
setPageIndex(newPage);
};
const handleSearchChange = (term: string) => {
setSearchTerm(term);
setPageIndex(0); // Reset to first page when search term changes
};
const handleRefresh = () => {
refetch();
};
if (isLoading) {
return (
<div className="p-6 flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="text-sm text-muted-foreground">
{t("organization.loadingAdmins")}
</div>
</div>
</div>
);
}
if (isError) {
return (
<div className="p-6 flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-4">
<div className="text-red-500 font-medium">
{t("organization.errorLoadingAdmins")}
</div>
<Button variant="outline" onClick={() => refetch()}>
{t("organization.retry")}
</Button>
</div>
</div>
);
}
return (
<>
<div className="p-6 space-y-6 dark:bg-gray-900">
<Card className="col-span-2 shadow-none border-none bg-transparent dark:bg-transparent px-0">
<CardHeader className="flex flex-row justify-between items-center px-0">
<CardTitle className="text-xl font-semibold dark:text-white">
{t("organization.organizationAdmins")}
</CardTitle>
</CardHeader>
<CardContent className="px-0 dark:bg-transparent">
<AdvancedTable
columns={OrganizationAdminsColumnDefn(
localizedName as (name?: { am?: string; en?: string }) => string
)}
data={organizationsAdminsResponse?.items || []}
tableName="Organization Admins"
toolBarPosition="right"
refresh={handleRefresh}
onGlobalFilterChange={handleSearchChange}
disableClientFiltering={true}
extraToolbar={
<div className="flex gap-2">
<Button
onClick={() => setIsAssignDialogOpen(true)}
className="bg-[#4A6CF7] hover:bg-[#3a5ad4] text-white px-5 py-2 rounded-md text-sm font-medium shadow-md"
>
<UserPlus className="w-4 h-4 mr-2" />
{t("organization.assignAdmin")}
</Button>
<Link to="/user-management/add_admin">
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
<Plus className="w-4 h-4 mr-2" />
{t("organization.addAdmin")}
</Button>
</Link>
</div>
}
itemCount={organizationsAdminsResponse?.count || 0}
pageIndex={pageIndex}
onPageChange={handlePageChange}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
/>
</CardContent>
</Card>
</div>
{/* Assign Admin Dialog */}
<AssignOrgAdminDialog
isOpen={isAssignDialogOpen}
onClose={() => setIsAssignDialogOpen(false)}
onSuccess={() => {
refetch();
toast.success(t("organization.adminAssignedSuccess"));
}}
/>
</>
);
}

View File

@@ -1,80 +0,0 @@
import React from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../../../shared/common/ui/dropdown-menu";
import { Edit, MoreHorizontal, Trash, UserCheck, UserX } from "lucide-react";
import { Button } from "../../../shared/common/ui/button";
import { Link } from "react-router-dom";
import { OrganizationAdmin } from "@/super-admin/services/api/organizationAdminService";
import { toast } from "sonner";
interface OrganizationAdminsActionsProps {
rowData: OrganizationAdmin;
}
const OrganizationAdminsActions: React.FC<OrganizationAdminsActionsProps> = ({
rowData,
}) => {
const handleDeleteClick = () => {
toast.warning("Delete functionality not implemented yet");
};
const handleStatusChange = (status: string) => {
toast.info(`Admin status change to ${status} not implemented yet`);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
to={`/add_admin?id=${rowData.id}`}
className="flex items-center w-full"
>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
{rowData.status !== "active" && (
<DropdownMenuItem
onClick={() => handleStatusChange("active")}
className="flex items-center"
>
<UserCheck className="mr-2 h-4 w-4" />
Activate
</DropdownMenuItem>
)}
{rowData.status === "active" && (
<DropdownMenuItem
onClick={() => handleStatusChange("inactive")}
className="flex items-center"
>
<UserX className="mr-2 h-4 w-4" />
Deactivate
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handleDeleteClick()}
className="flex items-center"
>
<Trash className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
export default OrganizationAdminsActions;

View File

@@ -1,77 +0,0 @@
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "../../../shared/common/ui/button";
import { ArrowUpDown, Eye } from "lucide-react";
import { OrganizationAdminsDto } from "@/shared/dto/organization/organizationDto";
import { StatusCell } from "./StatusCell";
import { t } from "i18next";
import { useLocalizedName } from "@/shared/common/localizedName";
import { Link } from "react-router-dom";
import OrganizationAdminsActions from "./OrganizationAdminsActions";
export const OrganizationAdminsColumnDefn = (
localizedName: (name?: { am?: string; en?: string }) => string,
): ColumnDef<OrganizationAdminsDto>[] => {
return [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="p-0 hover:bg-transparent text-gray-700 dark:text-gray-300"
style={{
padding: 0,
}}
>
{t("organization.name")}
<ArrowUpDown className="p-0 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => (
<div className="font-medium">{localizedName(row.original.name)}</div>
),
},
{
accessorKey: "status",
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="p-0 hover:bg-transparent text-gray-700 dark:text-gray-300"
style={{
padding: 0,
}}
>
{t("dashboard.Status")}
<ArrowUpDown className="h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const id = row.original.id;
return (
<StatusCell
id={id}
// isAssigned={row.original.isAssigned}
adminsCount={row.original.adminsCount}
/>
);
},
},
{
accessorKey: "actions",
header: t("organization.actions") || "Actions",
cell: ({ row }) => (
<div className="flex gap-2">
<Link to={`/super-admin/organizations/${row.original.id}`}>
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
</Link>
<OrganizationAdminsActions rowData={row.original as any} />
</div>
),
},
];
};

View File

@@ -0,0 +1,199 @@
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,
fetchAllUnitAdminById,
OrganizationAdminPayload,
} from "@/super-admin/services/api/organizationAdminService";
import {
assignOrgAdminRole,
removeOrgAdminRole,
removeUnitAdminRole,
} from "@/super-admin/services/api/userRoleService";
import {
activateUser,
deactivateUser,
} from "@/super-admin/services/api/userService";
import { resendVerificationCode } from "@/shared/services/authService";
import {
updateProfile,
UpdateProfilePayload,
} from "@/user-management/services/api/employeePositionsService";
export interface OrgAdminUserRole {
id: string;
organizationId?: string | null;
unitId?: string | null;
role?: { key?: string } | null;
}
/** User row returned by GET /organizations/all-admins/:id (userRoles.role relation included). */
export interface OrgAdminUser {
id: string;
name?: { am?: string; en?: string };
username?: string;
email?: string;
phoneNumber?: string;
isActive: boolean;
hasSetPassword: boolean;
status?: string;
createdAt?: string;
userRoles?: OrgAdminUserRole[];
}
export const ORG_ADMINS_KEY = "orgAdmins";
export const ORG_PICKER_KEY = "orgAdminsOrgPicker";
export interface RemoveAdminInput {
userId: string;
/** set for org-admin removal */
organizationId?: string;
/** set for unit-admin removal (wins over organizationId) */
unitId?: string;
}
export const useOrgAdmins = (
orgId?: string,
params?: { take?: number; skip?: number },
) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const {
data: adminsResponse,
isLoading,
isError,
refetch,
} = useQuery({
queryKey: [ORG_ADMINS_KEY, orgId, params],
queryFn: async () => {
const { data } = await fetchAllUnitAdminById(orgId as string, params);
return {
count: (data?.count ?? 0) as number,
items: (data?.items ?? []) as OrgAdminUser[],
};
},
enabled: !!orgId,
staleTime: 5 * 60 * 1000,
retry: false,
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: [ORG_ADMINS_KEY] });
// picker + org tables show adminsCount — keep them fresh
queryClient.invalidateQueries({ queryKey: [ORG_PICKER_KEY] });
queryClient.invalidateQueries({ queryKey: ["organizations"] });
queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] });
};
const { mutate: inviteAdmin, isPending: isInviting } = useMutation({
mutationFn: async (payload: OrganizationAdminPayload) => {
const { data } = await assignOrganizationAdmin(payload);
return data;
},
onSuccess: () => {
toast.success(t("orgAdmins.toasts.invited"));
invalidate();
},
onError: handleError,
});
const { mutate: assignAdmin, isPending: isAssigning } = useMutation({
mutationFn: async (payload: { organizationId: string; userId: string }) => {
const { data } = await assignOrgAdminRole(payload);
return data;
},
onSuccess: () => {
toast.success(t("orgAdmins.toasts.assigned"));
invalidate();
},
onError: handleError,
});
const { mutate: removeAdmin, isPending: isRemoving } = useMutation({
mutationFn: async ({ userId, organizationId, unitId }: RemoveAdminInput) => {
const { data } = unitId
? await removeUnitAdminRole({ unitId, userId })
: await removeOrgAdminRole({
organizationId: organizationId as string,
userId,
});
return data;
},
onSuccess: () => {
toast.success(t("orgAdmins.toasts.removed"));
invalidate();
},
onError: handleError,
});
const { mutate: resendInvite, isPending: isResending } = useMutation({
mutationFn: async (payload: { email: string; phoneNumber: string }) => {
const { data } = await resendVerificationCode(payload);
return data;
},
onSuccess: () => {
toast.success(t("orgAdmins.toasts.resent"));
},
onError: handleError,
});
const { mutate: toggleActive, isPending: isToggling } = useMutation({
mutationFn: async ({ id, activate }: { id: string; activate: boolean }) => {
const { data } = activate
? await activateUser(id)
: await deactivateUser(id);
return data;
},
onSuccess: (_data, variables) => {
toast.success(
variables.activate
? t("orgAdmins.toasts.activated")
: t("orgAdmins.toasts.deactivated"),
);
invalidate();
},
onError: handleError,
});
const { mutate: updateAdminProfile, isPending: isUpdatingProfile } =
useMutation({
mutationFn: async ({
id,
payload,
}: {
id: string;
payload: UpdateProfilePayload;
}) => {
const { data } = await updateProfile(payload, id);
return data;
},
onSuccess: () => {
toast.success(t("orgAdmins.toasts.profileUpdated"));
invalidate();
},
onError: handleError,
});
return {
adminsResponse,
isLoading,
isError,
refetch,
inviteAdmin,
isInviting,
assignAdmin,
isAssigning,
removeAdmin,
isRemoving,
resendInvite,
isResending,
toggleActive,
isToggling,
updateAdminProfile,
isUpdatingProfile,
};
};

View File

@@ -280,7 +280,7 @@ export const CreatePositionForm = ({
navigate("/user-management/position-management");
}
} catch {
toast.error(t("contentManagement.permissionSuccess"));
toast.error(t("contentManagement.permissionFailed"));
}
};