mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 23:35:42 +00:00
feat: rebuild the admin pages
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty } from "@nestjs/swagger";
|
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 {
|
class CreateOrganizationUserNameDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -29,7 +30,8 @@ export class CreateOrganizationUserDto {
|
|||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
|
|
||||||
@ApiProperty({ type: CreateOrganizationUserNameDto })
|
@ApiProperty({ type: CreateOrganizationUserNameDto })
|
||||||
@IsObject()
|
@ValidateNested()
|
||||||
|
@Type(() => CreateOrganizationUserNameDto)
|
||||||
name!: CreateOrganizationUserNameDto;
|
name!: CreateOrganizationUserNameDto;
|
||||||
|
|
||||||
@ApiProperty({ required: false, default: false })
|
@ApiProperty({ required: false, default: false })
|
||||||
|
|||||||
@@ -2585,7 +2585,6 @@
|
|||||||
"archiveDepartment": "የስራ መደብ መጠርያ አርክብ አድርግ",
|
"archiveDepartment": "የስራ መደብ መጠርያ አርክብ አድርግ",
|
||||||
"deleteDepartment": "የስራ መደብ መጠርያ ሰርዝ",
|
"deleteDepartment": "የስራ መደብ መጠርያ ሰርዝ",
|
||||||
"deleteConfirm": "የስራ መደብ መጠርያ ይሰርዝ?",
|
"deleteConfirm": "የስራ መደብ መጠርያ ይሰርዝ?",
|
||||||
"delete": "ሰርዝ",
|
|
||||||
"deleteFailed": "የስራ መደብ መጠርያ ሰረዝ ወደ ተሳክቶ",
|
"deleteFailed": "የስራ መደብ መጠርያ ሰረዝ ወደ ተሳክቶ",
|
||||||
"cannotDeleteWithEmployees": "ተመድቦ ካለበት ሰራተኞች ጋር የስራ መደብ መጠርያ መሰረዝ አይቻልም",
|
"cannotDeleteWithEmployees": "ተመድቦ ካለበት ሰራተኞች ጋር የስራ መደብ መጠርያ መሰረዝ አይቻልም",
|
||||||
"reassignEmployeesFirst": "እባክዎ ሁሉንም ሰራተኞች በዚህ ክፍል ውስጥ ዳግም ይሰጧቸው ወይም ያስወግዱ።",
|
"reassignEmployeesFirst": "እባክዎ ሁሉንም ሰራተኞች በዚህ ክፍል ውስጥ ዳግም ይሰጧቸው ወይም ያስወግዱ።",
|
||||||
@@ -3117,7 +3116,7 @@
|
|||||||
"referenceNumberStyles": "የማጣቀሻ ቁጥር ስታይሎች",
|
"referenceNumberStyles": "የማጣቀሻ ቁጥር ስታይሎች",
|
||||||
"other": "ሌላ",
|
"other": "ሌላ",
|
||||||
"styleCategories": "የስታይል ምድቦች",
|
"styleCategories": "የስታይል ምድቦች",
|
||||||
"styleEditor": "ስታይል አርታኢ",
|
"styleEditor": "የስታይል አርታዒ",
|
||||||
"livePreview": "የቀጥታ ቅድመ-እይታ",
|
"livePreview": "የቀጥታ ቅድመ-እይታ",
|
||||||
"fontsHint": "አማራጭ የቅርጸ-ቁምፊ ሪሶርስ ይምረጡ።",
|
"fontsHint": "አማራጭ የቅርጸ-ቁምፊ ሪሶርስ ይምረጡ።",
|
||||||
"fontResource": "የቅርጸ-ቁምፊ ፋይል",
|
"fontResource": "የቅርጸ-ቁምፊ ፋይል",
|
||||||
@@ -3209,11 +3208,9 @@
|
|||||||
"previewLanguage": "የቅድመ-እይታ ቋንቋ",
|
"previewLanguage": "የቅድመ-እይታ ቋንቋ",
|
||||||
"amharic": "አማርኛ",
|
"amharic": "አማርኛ",
|
||||||
"english": "እንግሊዝኛ",
|
"english": "እንግሊዝኛ",
|
||||||
"styleCategories": "የስታይል ምድቦች",
|
|
||||||
"categoryHint": "ለማርትዕ ክፍል ይምረጡ",
|
"categoryHint": "ለማርትዕ ክፍል ይምረጡ",
|
||||||
"expandSidebar": "የጎን ማውጫን ዘርጋ",
|
"expandSidebar": "የጎን ማውጫን ዘርጋ",
|
||||||
"collapseSidebar": "የጎን ማውጫን ጠቅልል",
|
"collapseSidebar": "የጎን ማውጫን ጠቅልል",
|
||||||
"styleEditor": "የስታይል አርታዒ",
|
|
||||||
"headerFooterSelection": "ራስጌ እና ግርጌ",
|
"headerFooterSelection": "ራስጌ እና ግርጌ",
|
||||||
"noSettings": "ምንም የስታይል ቅንብሮች የሉም",
|
"noSettings": "ምንም የስታይል ቅንብሮች የሉም",
|
||||||
"visible": "የሚታይ",
|
"visible": "የሚታይ",
|
||||||
@@ -7393,5 +7390,87 @@
|
|||||||
"department": "ዲፓርትመንት",
|
"department": "ዲፓርትመንት",
|
||||||
"unit": "ክፍል",
|
"unit": "ክፍል",
|
||||||
"notAvailable": "ያልዋቀረ"
|
"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",
|
"department": "Department",
|
||||||
"unit": "Unit",
|
"unit": "Unit",
|
||||||
"notAvailable": "Not Available"
|
"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();
|
refetch();
|
||||||
refetchAdmins();
|
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" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
{t("organization.retry")}
|
{t("organization.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -140,7 +141,8 @@ const DashboardPage = () => {
|
|||||||
<Link to="/organizations">
|
<Link to="/organizations">
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
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")}
|
{t("organization.viewMore")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import OrganizationsAdmins from "@/super-admin/components/organizationAdmins/OrganizationAdmins";
|
import OrgAdminsPage from "@/super-admin/components/org-admins/OrgAdminsPage";
|
||||||
|
|
||||||
const OrganizationAdminsPage = () => {
|
const OrganizationAdminsPage = () => {
|
||||||
return <OrganizationsAdmins />;
|
return <OrgAdminsPage />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default OrganizationAdminsPage;
|
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");
|
navigate("/user-management/position-management");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("contentManagement.permissionSuccess"));
|
toast.error(t("contentManagement.permissionFailed"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user