mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: rebuild the admin pages
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -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": "መገለጫው ተዘምኗል!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -280,7 +280,7 @@ export const CreatePositionForm = ({
|
||||
navigate("/user-management/position-management");
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("contentManagement.permissionSuccess"));
|
||||
toast.error(t("contentManagement.permissionFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user