mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
user management ui
This commit is contained in:
11
apps/edr-freight-web/backoffice/src/super-admin/app/store.ts
Normal file
11
apps/edr-freight-web/backoffice/src/super-admin/app/store.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import dashboardReducer from "../components/dashboard/dashboardSlice";
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
dashboard: dashboardReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,44 @@
|
||||
import { Activity } from "@/super-admin/hooks/useDashboardData";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { t } from "i18next";
|
||||
|
||||
interface ActivityTimelineProps {
|
||||
activities: Activity[];
|
||||
}
|
||||
|
||||
export const ActivityTimeline = ({ activities }: ActivityTimelineProps) => {
|
||||
if (!activities || activities.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground text-center py-4">
|
||||
{t("organization.noRecentActivities")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative pl-6">
|
||||
<div className="absolute left-2 top-2 bottom-2 w-0.5 bg-purple-500" />
|
||||
<ul className="space-y-6">
|
||||
{activities.map((activity) => (
|
||||
<li key={activity.id} className="relative pl-4">
|
||||
<div className="absolute left-0 top-1 w-3 h-3 bg-purple-500 rounded-full" />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(activity.timestamp), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{activity.description}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
By{" "}
|
||||
<span className="font-medium text-purple-600">
|
||||
{activity.user}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useTenantConfig } from "@/layout/components/TenantConfig";
|
||||
|
||||
export const HeaderBar = () => {
|
||||
const { config: tenantConfig } = useTenantConfig();
|
||||
return (
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-100">
|
||||
{tenantConfig.appName} Dashboard
|
||||
</h1>
|
||||
<button className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-md text-sm">
|
||||
+ Add Organization
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/common/ui/table";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||
import { t } from "i18next";
|
||||
interface OrgTableProps {
|
||||
organizations: OrganizationDto[];
|
||||
}
|
||||
|
||||
export const OrgTable = ({ organizations }: OrgTableProps) => {
|
||||
if (!organizations || organizations.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground text-center py-4 border dark:border-gray-700 rounded-xl p-6 dark:bg-gray-800">
|
||||
No organizations found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl overflow-hidden shadow-sm border bg-white dark:bg-gray-800 dark:border-gray-700">
|
||||
<Table>
|
||||
<TableHeader className="bg-white dark:bg-gray-800 text-muted-foreground">
|
||||
<TableRow>
|
||||
<TableHead className="px-6 py-3">{t("organization.organizationName")}</TableHead>
|
||||
{/* <TableHead className="px-6 py-3">{t("organization.key")}</TableHead> */}
|
||||
<TableHead className="px-6 py-3">{t("organization.createdOn")}</TableHead>
|
||||
<TableHead className="px-6 py-3">{t("dashboard.Status")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{organizations.map((org, index) => (
|
||||
<TableRow
|
||||
key={org.id}
|
||||
className={index % 2 ? "bg-purple-50/20 dark:bg-gray-700/40" : "bg-white dark:bg-gray-800"}
|
||||
>
|
||||
<TableCell className="px-6 py-3 font-medium">
|
||||
{org.name?.en || "N/A"}
|
||||
</TableCell>
|
||||
{/* <TableCell className="px-6 py-3">{org.key || "N/A"}</TableCell> */}
|
||||
<TableCell className="px-6 py-3">
|
||||
{org.createdAt
|
||||
? formatDistanceToNow(new Date(org.createdAt), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
<TableCell className="px-6 py-3">
|
||||
<Badge
|
||||
variant={org.status === "Active" ? "default" : "outline"}
|
||||
className={
|
||||
org.status === "Active"
|
||||
? "bg-primary-100 text-primary-800 hover:bg-primary-100 dark:bg-primary-900/50 dark:text-primary-300"
|
||||
: "bg-gray-100 text-gray-800 hover:bg-gray-100 dark:bg-gray-700 dark:text-gray-300"
|
||||
}
|
||||
>
|
||||
{org.status === "Active" ? t("statusBar.activate") : t("statusBar.deactivate")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Card, CardContent } from "@/shared/common/ui/card";
|
||||
import { cn } from "@/super-admin/lib/utils";
|
||||
import { LucideIcon, Building2, Clock, User } from "lucide-react";
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: number;
|
||||
indicator?: string;
|
||||
color: string;
|
||||
icon: "building" | "clock" | "user";
|
||||
variant?: "primary" | "default";
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
building: Building2,
|
||||
clock: Clock,
|
||||
user: User,
|
||||
};
|
||||
|
||||
export const StatCard: React.FC<StatCardProps> = ({
|
||||
title,
|
||||
value,
|
||||
indicator,
|
||||
color,
|
||||
icon,
|
||||
variant = "default",
|
||||
onClick,
|
||||
}) => {
|
||||
const Icon = iconMap[icon];
|
||||
|
||||
return (
|
||||
<Card
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"p-4 rounded-xl shadow-sm transition-colors",
|
||||
variant === "primary" ? "bg-purple-600 text-white" : "bg-white dark:bg-gray-800 dark:border-gray-700",
|
||||
onClick && "cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-sm font-medium opacity-80">{title}</div>
|
||||
<Icon className="w-5 h-5 opacity-60" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold leading-snug">{value}</div>
|
||||
{indicator && (
|
||||
<div className={cn("text-xs mt-1", color)}>{indicator}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
interface DashboardState {
|
||||
totalOrgs: number;
|
||||
pendingRequests: number;
|
||||
orgAdmins: number;
|
||||
}
|
||||
|
||||
const initialState: DashboardState = {
|
||||
totalOrgs: 0,
|
||||
pendingRequests: 0,
|
||||
orgAdmins: 0,
|
||||
};
|
||||
|
||||
const dashboardSlice = createSlice({
|
||||
name: "dashboard",
|
||||
initialState,
|
||||
reducers: {
|
||||
setStats(state, action: PayloadAction<DashboardState>) {
|
||||
return { ...state, ...action.payload };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setStats } = dashboardSlice.actions;
|
||||
export default dashboardSlice.reducer;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { getDashboardStats } from "@/super-admin/services/api/dashboardApi";
|
||||
import { DashboardStatsDto } from "../types/dashboardStatsDto";
|
||||
|
||||
export const useDashboardStats = () => {
|
||||
const {
|
||||
data: stats,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["dashboardStats"],
|
||||
queryFn: async (): Promise<DashboardStatsDto> => {
|
||||
try {
|
||||
return await getDashboardStats();
|
||||
} catch (err) {
|
||||
toast("Failed to fetch dashboard stats. Using mock data.");
|
||||
return err instanceof Error
|
||||
? Promise.reject(new Error(err.message))
|
||||
: Promise.reject(new Error("Failed to fetch dashboard stats."));
|
||||
// Fallback to mock data if API call fails
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
return {
|
||||
stats,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface DashboardStatsDto {
|
||||
totalOrgs: number;
|
||||
pendingRequests: number;
|
||||
orgAdmins: number;
|
||||
percentChanges: {
|
||||
orgs: number;
|
||||
pending: number;
|
||||
admins: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ExternalUserDto } from "@/super-admin/dto/ExternalUsersDto";
|
||||
import { format } from "date-fns";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { UserStatusToggleConfirm } from "./UserStatusToggleConfirm";
|
||||
import { t } from "i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export const ExternalUsersColumnDefn = (
|
||||
localizedName: (name?: { am: string; en: string }) => string,
|
||||
navigate: ReturnType<typeof useNavigate>
|
||||
): ColumnDef<ExternalUserDto>[] => {
|
||||
return [
|
||||
{
|
||||
accessorKey: "name.en",
|
||||
header: () => t("organization.username"),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">{localizedName(row.original.name)}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: () => t("organization.updatedOn"),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">
|
||||
{format(new Date(row.original.updatedAt), "MMM dd, yyyy")}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "userType",
|
||||
header: () => "User Type",
|
||||
cell: ({ row }) => {
|
||||
const userType = row.original.userType;
|
||||
const displayText =
|
||||
userType === "external_organization"
|
||||
? "External Organization"
|
||||
: "Employee";
|
||||
|
||||
return (
|
||||
<div className="font-medium">
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
userType === "external_organization"
|
||||
? "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300"
|
||||
: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300"
|
||||
}`}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => t("dashboard.Status"),
|
||||
cell: ({ row }) => {
|
||||
const { status } = row.original;
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "accepted":
|
||||
return "bg-primary-100 text-primary-600 hover:bg-primary-100 dark:bg-primary-900/40 dark:text-primary-300 dark:hover:bg-primary-900/50";
|
||||
case "rejected":
|
||||
return "bg-red-100 text-red-600 hover:bg-red-100 dark:bg-red-900/40 dark:text-red-300 dark:hover:bg-red-900/50";
|
||||
case "pending":
|
||||
default:
|
||||
return "bg-blue-100 text-blue-600 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-300 dark:hover:bg-blue-900/50";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case "accepted":
|
||||
return "Accepted";
|
||||
case "rejected":
|
||||
return "Rejected";
|
||||
case "pending":
|
||||
default:
|
||||
return t("statusBar.Pending");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Badge
|
||||
className={`${getStatusColor(
|
||||
status
|
||||
)} rounded-full px-6 py-1 font-medium`}
|
||||
>
|
||||
{getStatusText(status)}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => t("userRecord.Actions"),
|
||||
cell: ({ row }) => {
|
||||
const { id, status } = row.original;
|
||||
const handleViewUploads = () => {
|
||||
navigate(`/user-management/external_users/view/${id}`);
|
||||
};
|
||||
// const handleEdit = () => {
|
||||
// navigate(`/user-management/external_users/edit/${id}`);
|
||||
// };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<UserStatusToggleConfirm userId={id} userStatus={status} />
|
||||
<button
|
||||
onClick={handleViewUploads}
|
||||
className="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-sm font-medium text-gray-800 dark:text-gray-200"
|
||||
>
|
||||
{t("View")}
|
||||
</button>
|
||||
{/* <button
|
||||
onClick={handleEdit}
|
||||
className="px-2 py-1 bg-blue-100 rounded hover:bg-blue-200 text-sm font-medium"
|
||||
>
|
||||
{t("Edit")}
|
||||
</button> */}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { AdvancedTable } from "../../../shared/common/ui/table/AdvancedTable";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/common/ui/card";
|
||||
import {
|
||||
useAllExternalUsers,
|
||||
userTypeEnum,
|
||||
} from "@/super-admin/hooks/useExternalUsers";
|
||||
import { ExternalUsersColumnDefn } from "./ExternalUsersColumnDefn";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RadioGroup, RadioGroupItem } from "@/shared/common/ui/radio-group";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
CombinedFilterBar,
|
||||
CombinedFilterField,
|
||||
} from "@/shared/components/filters/CombinedFilterBar";
|
||||
import { useCombinedFilters } from "@/shared/hooks/useCombinedFilters";
|
||||
import { FilterParams } from "@/shared/utils/filterParams";
|
||||
|
||||
export default function PendingExternalUsers() {
|
||||
const localizedName = useLocalizedName();
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const pageSize = 10;
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const filterStore = useCombinedFilters<FilterParams>();
|
||||
const [selectedUserType, setSelectedUserType] = useState<userTypeEnum>(
|
||||
userTypeEnum.externalUsers,
|
||||
);
|
||||
|
||||
const filterFields = useMemo<CombinedFilterField[]>(
|
||||
() => [
|
||||
{ key: "name", label: t("profile.name", "Name"), type: "text" },
|
||||
{ key: "email", label: t("profile.emailmsg", "Email"), type: "text" },
|
||||
{
|
||||
key: "username",
|
||||
label: t("profile.username", "Username"),
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "phoneNumber",
|
||||
label: t("profile.phoneNumber", "Phone Number"),
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const { data, isLoading } = useAllExternalUsers({
|
||||
take: pageSize,
|
||||
skip: pageIndex * pageSize,
|
||||
orderBy: "updatedAt:Desc",
|
||||
userType: selectedUserType,
|
||||
...filterStore.filters,
|
||||
});
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPageIndex(newPage);
|
||||
};
|
||||
|
||||
const handleFilterChange = () => {
|
||||
setPageIndex(0);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center p-6">
|
||||
<Loader2 className="animate-spin w-6 h-6 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="col-span-2 shadow-none border-none bg-transparent px-0">
|
||||
<CardHeader className="flex flex-col gap-4 px-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="text-xl font-semibold">All Users</CardTitle>
|
||||
|
||||
<RadioGroup
|
||||
value={selectedUserType}
|
||||
onValueChange={(val: userTypeEnum) => {
|
||||
setSelectedUserType(val);
|
||||
setPageIndex(0);
|
||||
}}
|
||||
className="flex flex-wrap gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value={userTypeEnum.externalUsers}
|
||||
id="allExternal"
|
||||
/>
|
||||
<Label htmlFor="allExternal">All External Users</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={userTypeEnum.external} id="external" />
|
||||
<Label htmlFor="external">External Organization</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={userTypeEnum.individual} id="individual" />
|
||||
<Label htmlFor="individual">Individual</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4 px-0">
|
||||
<CombinedFilterBar
|
||||
fields={filterFields}
|
||||
filters={filterStore.filters}
|
||||
setFilter={filterStore.setFilter}
|
||||
setFilters={filterStore.setFilters}
|
||||
removeFilter={filterStore.removeFilter}
|
||||
removeFilters={filterStore.removeFilters}
|
||||
onFilterChange={handleFilterChange}
|
||||
className="w-full min-w-0"
|
||||
/>
|
||||
|
||||
<AdvancedTable
|
||||
columns={ExternalUsersColumnDefn(localizedName, navigate)}
|
||||
data={data?.items || []}
|
||||
tableName="ExternalUsers"
|
||||
toolBarPosition="right"
|
||||
itemCount={data?.count || 0}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={handlePageChange}
|
||||
nextFunction={() => handlePageChange(pageIndex + 1)}
|
||||
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
|
||||
hideToolbarFilter
|
||||
disableClientFiltering
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { Textarea } from "@/shared/common/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Status = "pending" | "submitted" | "approved" | "adjusted" | "rejected";
|
||||
|
||||
interface ActionModalProps {
|
||||
documentId: string;
|
||||
onActionSubmit: (documentId: string, status: Status, remark: string) => void;
|
||||
}
|
||||
|
||||
const ActionModal = ({ documentId, onActionSubmit }: ActionModalProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [status, setStatus] = useState<Status>("approved");
|
||||
const [remark, setRemark] = useState("");
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = () => {
|
||||
onActionSubmit(documentId, status, remark);
|
||||
|
||||
// Translate status for toast message
|
||||
const getTranslatedStatus = (status: Status): string => {
|
||||
switch (status) {
|
||||
case "approved":
|
||||
return t("actionModal.actions.approve");
|
||||
case "adjusted":
|
||||
return t("actionModal.actions.adjustment");
|
||||
case "rejected":
|
||||
return t("actionModal.actions.reject");
|
||||
case "pending":
|
||||
return t("actionModal.actions.pending");
|
||||
case "submitted":
|
||||
return t("actionModal.actions.submitted");
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
toast.success(
|
||||
t("actionModal.toast.success", {
|
||||
status: getTranslatedStatus(status),
|
||||
})
|
||||
);
|
||||
setOpen(false);
|
||||
setRemark("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
{t("actionModal.trigger")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("actionModal.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
{t("actionModal.labels.selectAction")}
|
||||
</label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(val) => setStatus(val as Status)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t("actionModal.actions.selectPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="approved">
|
||||
{t("actionModal.actions.approve")}
|
||||
</SelectItem>
|
||||
<SelectItem value="adjusted">
|
||||
{t("actionModal.actions.adjustment")}
|
||||
</SelectItem>
|
||||
<SelectItem value="rejected">
|
||||
{t("actionModal.actions.reject")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pending">
|
||||
{t("actionModal.actions.pending")}
|
||||
</SelectItem>
|
||||
<SelectItem value="submitted">
|
||||
{t("actionModal.actions.submitted")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
{t("actionModal.labels.remark")}
|
||||
</label>
|
||||
<Textarea
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.target.value)}
|
||||
placeholder={t("actionModal.labels.remarkPlaceholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
{t("actionModal.buttons.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
{t("actionModal.buttons.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionModal;
|
||||
@@ -0,0 +1,332 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { CardContent } from "@/shared/common/ui/card";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import {
|
||||
ChevronLeft,
|
||||
CircleUser,
|
||||
Mail,
|
||||
Phone,
|
||||
UserPen,
|
||||
RefreshCw,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { useUsersByID } from "@/super-admin/hooks/useUsers";
|
||||
import { updateUserProfile } from "@/user-management/services/api/employeePositionsService";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
const UpdateProfile = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: userData, refetch, isLoading } = useUsersByID(id!);
|
||||
const [userName, setUserName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [englishName, setEnglishName] = useState("");
|
||||
const [amharicName, setAmharicName] = useState("");
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||
const [error, setError] = useState("");
|
||||
const { handleError } = useErrorHandler(t);
|
||||
// Populate form fields when user data is fetched
|
||||
useEffect(() => {
|
||||
if (userData) {
|
||||
const user = userData;
|
||||
setUserName(user.username ?? "");
|
||||
setEmail(user.email ?? "");
|
||||
setEnglishName(user.name?.en ?? "");
|
||||
setAmharicName(user.name?.am ?? "");
|
||||
setPhoneNumber(user.phoneNumber ?? "");
|
||||
}
|
||||
}, [userData]);
|
||||
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
show: { opacity: 1, transition: { staggerChildren: 0.1 } },
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const newErrors: { [key: string]: string } = {};
|
||||
|
||||
if (!email.trim()) newErrors.email = t("profile.emailRequired");
|
||||
if (!userName.trim()) newErrors.userName = t("profile.usernameRequired");
|
||||
if (!phoneNumber.trim()) newErrors.phoneNumber = t("profile.phoneRequired");
|
||||
if (!englishName.trim())
|
||||
newErrors.englishName = t("profile.englishNameRequired");
|
||||
if (!amharicName.trim())
|
||||
newErrors.amharicName = t("profile.amharicNameRequired");
|
||||
|
||||
setErrors(newErrors);
|
||||
if (Object.keys(newErrors).length > 0) return;
|
||||
|
||||
// Phone number validation
|
||||
const phoneRegex = /^(\+2519\d{8}|09\d{8})$/;
|
||||
if (!phoneRegex.test(phoneNumber)) {
|
||||
setError(t("profile.invalidPhoneFormat"));
|
||||
toast.error(t("profile.invalidPhoneFormat"), {
|
||||
description: t("profile.validPhoneFormat"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
setError(t("profile.invalidEmailFormat"));
|
||||
toast.error(t("profile.invalidEmailFormat"), {
|
||||
description: t("profile.validEmailFormat"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateUserProfile(
|
||||
{
|
||||
username: userName,
|
||||
email,
|
||||
phoneNumber,
|
||||
name: { en: englishName, am: amharicName },
|
||||
},
|
||||
id as string,
|
||||
);
|
||||
|
||||
toast.success(t("profile.profileUpdateSuccess"), {
|
||||
description: t("profile.profileUpdated"),
|
||||
});
|
||||
|
||||
refetch(); // refresh data after update
|
||||
} catch (err: any) {
|
||||
const message = err?.message || t("profile.profileUpdateFailed");
|
||||
setError(message);
|
||||
handleError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
|
||||
const user = userData;
|
||||
if (!user) return <div>User not found</div>;
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100/40 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6">
|
||||
<Button
|
||||
onClick={() => navigate(-1)}
|
||||
variant="ghost"
|
||||
className="flex items-center gap-2 text-primary hover:text-primary-700 hover:bg-primary-300/20 dark:text-primary-300 dark:hover:text-primary-300 px-4 py-2 rounded-full transition-colors">
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
<span className="font-medium">{t("common.back")}</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Profile Content */}
|
||||
<div className="flex justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="w-full max-w-2xl bg-white dark:bg-gray-800 rounded-2xl shadow-xl overflow-hidden border border-gray-100 dark:border-gray-700">
|
||||
{/* Profile Header */}
|
||||
<div className="relative bg-gradient-to-r from-primary to-primary-300 dark:from-primary-700 dark:to-primary-300 h-32">
|
||||
<div className="absolute -bottom-12 left-1/2 transform -translate-x-1/2">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className="relative h-24 w-24 rounded-full border-4 border-white dark:border-gray-800 bg-white dark:bg-gray-800 shadow-lg">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary-300 to-primary dark:from-primary-300 dark:to-primary-700 rounded-full flex items-center justify-center">
|
||||
<CircleUser className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Info */}
|
||||
<div className="pt-14 pb-6 px-6 text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100">
|
||||
{user.name.en || "___"}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mt-1">
|
||||
{t("profile.updateProfile")}
|
||||
</p>
|
||||
|
||||
<div className="mt-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||
user.status === "accepted"
|
||||
? "bg-primary-300/20 text-primary border-primary/30"
|
||||
: "bg-gray-100 text-gray-600 border-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600"
|
||||
}`}>
|
||||
{t(`statusBar.${user.status || "___"}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Update Form */}
|
||||
<div className="px-6 pb-8">
|
||||
<motion.form
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="space-y-4"
|
||||
onSubmit={handleSubmit}>
|
||||
{/* Username */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 pl-1">
|
||||
{t("profile.username")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<UserPen className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<Input
|
||||
className="pl-9 rounded-lg border-gray-300 dark:border-gray-600 focus:border-primary focus:ring-primary dark:bg-gray-700 dark:text-gray-100"
|
||||
value={userName}
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{errors.userName && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 pl-1">
|
||||
{errors.userName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* English Name */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 pl-1">
|
||||
{t("profile.englishName")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<UserPen className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<Input
|
||||
className="pl-9 rounded-lg border-gray-300 dark:border-gray-600 focus:border-primary focus:ring-primary dark:bg-gray-700 dark:text-gray-100"
|
||||
value={englishName}
|
||||
onChange={(e) => setEnglishName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{errors.englishName && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 pl-1">
|
||||
{errors.englishName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amharic Name */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 pl-1">
|
||||
{t("profile.amharicName")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<UserPen className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<Input
|
||||
className="pl-9 rounded-lg border-gray-300 dark:border-gray-600 focus:border-primary focus:ring-primary dark:bg-gray-700 dark:text-gray-100"
|
||||
value={amharicName}
|
||||
onChange={(e) => setAmharicName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{errors.amharicName && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 pl-1">
|
||||
{errors.amharicName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 pl-1">
|
||||
{t("profile.emailmsg")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<Input
|
||||
type="email"
|
||||
className="pl-9 rounded-lg border-gray-300 dark:border-gray-600 focus:border-primary focus:ring-primary dark:bg-gray-700 dark:text-gray-100"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 pl-1">
|
||||
{errors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone Number */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 pl-1">
|
||||
{t("profile.phoneNumber")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Phone className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<Input
|
||||
type="tel"
|
||||
className="pl-9 rounded-lg border-gray-300 dark:border-gray-600 focus:border-primary focus:ring-primary dark:bg-gray-700 dark:text-gray-100"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{errors.phoneNumber && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 pl-1">
|
||||
{errors.phoneNumber}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className="pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 bg-gradient-to-r from-primary to-primary-300 hover:from-primary-700 hover:to-primary-300 text-white font-medium rounded-lg shadow-md transition-all duration-200"
|
||||
disabled={loading}>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<RefreshCw className="animate-spin h-5 w-5 mr-2" />
|
||||
{t("profile.updating")}...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{t("profile.updateProfile")}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.form>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateProfile;
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { useApproveExternalUser } from "@/super-admin/hooks/useExternalUsers";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import { UserStatus } from "@/super-admin/dto/ExternalUsersDto";
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
userStatus: UserStatus;
|
||||
}
|
||||
|
||||
export const UserStatusToggleConfirm = ({ userId, userStatus }: Props) => {
|
||||
const approve = useApproveExternalUser();
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
const isLoading = approve.isPending;
|
||||
|
||||
const onConfirm = (nextStatus: UserStatus) => {
|
||||
approve.mutate(
|
||||
{ id: userId, status: nextStatus },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
nextStatus === "accepted"
|
||||
? "User successfully approved"
|
||||
: "User successfully rejected"
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{/* Approve Dialog - Only show for pending users */}
|
||||
{userStatus === "pending" && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
className="bg-primary-600 text-white hover:bg-primary-700"
|
||||
size="sm"
|
||||
disabled={isLoading}>
|
||||
{t("statusBar.Approve")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("organization.approveUserPrompt")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("organization.approveUserDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>
|
||||
{t("common.Cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onConfirm("accepted")}
|
||||
disabled={isLoading}>
|
||||
{isLoading
|
||||
? t("organization.approving")
|
||||
: t("organization.yesApprove")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
{/* Reject Dialog - Only show for pending users */}
|
||||
{userStatus === "pending" && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
size="sm"
|
||||
disabled={isLoading}>
|
||||
{t("statusBar.Reject")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("organization.rejectUserPrompt")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("organization.rejectUserDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>
|
||||
{t("common.Cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onConfirm("rejected")}
|
||||
disabled={isLoading}>
|
||||
{isLoading
|
||||
? t("organization.rejecting")
|
||||
: t("organization.yesReject")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,378 @@
|
||||
import { useQuery, useQueries } from "@tanstack/react-query";
|
||||
import { getMyUploadedFiles } from "@/external-portal/services/portalOutgoingService";
|
||||
import { getDocumentRequirementsById } from "@/shared/services/organizationsService";
|
||||
import { getUserById } from "@/super-admin/services/api/userService";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { ScrollArea } from "@/shared/common/ui/scroll-area";
|
||||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||
import { Alert, AlertDescription } from "@/shared/common/ui/alert";
|
||||
import { FileText, RefreshCw, User, Mail, Phone, Building } from "lucide-react";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import {
|
||||
DocumentRequirementResponseDto,
|
||||
DocumentResponseDto,
|
||||
} from "@/shared/dto/External-Portal/External-PortalDto";
|
||||
import ActionModal from "./ResponseActions";
|
||||
import { toast } from "sonner";
|
||||
import { useGiveResponse } from "@/shared/hooks/useOrganizationReport";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/common/ui/tooltip";
|
||||
|
||||
type ViewDocumentProps = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
interface UploadsResponse {
|
||||
items: DocumentResponseDto[];
|
||||
counts: number;
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
id: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
username: string;
|
||||
userType: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const ViewDocument: React.FC<ViewDocumentProps> = ({ userId }) => {
|
||||
const localizedName = useLocalizedName();
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<"APPROVED" | "REJECTED" | "PENDING">(
|
||||
"APPROVED"
|
||||
);
|
||||
|
||||
// Utility function to truncate email if too long
|
||||
const truncateEmail = (email: string, maxLength: number = 25) => {
|
||||
if (email.length <= maxLength) return email;
|
||||
const [localPart, domain] = email.split("@");
|
||||
if (localPart.length > maxLength - 10) {
|
||||
return `${localPart.substring(0, maxLength - 10)}...@${domain}`;
|
||||
}
|
||||
return email;
|
||||
};
|
||||
|
||||
const { mutate, isLoading: isGiveResponseLoading } = useGiveResponse();
|
||||
|
||||
// 1️⃣ Fetch user information
|
||||
const {
|
||||
data: userInfo,
|
||||
isLoading: isUserLoading,
|
||||
isError: isUserError,
|
||||
} = useQuery<UserInfo, Error>({
|
||||
queryKey: ["user-info", userId],
|
||||
queryFn: async () => {
|
||||
const res = await getUserById(userId);
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
// 2️⃣ Fetch uploaded documents
|
||||
const {
|
||||
data: uploadedDocuments,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
isRefetching,
|
||||
} = useQuery<UploadsResponse, Error>({
|
||||
queryKey: ["external-letters", userId],
|
||||
queryFn: async () => {
|
||||
const res = await getMyUploadedFiles(userId);
|
||||
return {
|
||||
items: res.data.items,
|
||||
counts: res.data.counts,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// 3️⃣ Fetch requirements for each uploaded document
|
||||
const documentRequirementsQueries = useQueries({
|
||||
queries:
|
||||
uploadedDocuments?.items.map((doc) => ({
|
||||
queryKey: ["document-requirements", doc.documentId],
|
||||
queryFn: async (): Promise<DocumentRequirementResponseDto> => {
|
||||
const res = await getDocumentRequirementsById(doc.documentId);
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!uploadedDocuments && uploadedDocuments.items.length > 0,
|
||||
})) ?? [],
|
||||
});
|
||||
|
||||
// 4️⃣ Action submit handler
|
||||
const handleActionSubmit = async (
|
||||
documentId: string,
|
||||
actionStatus:
|
||||
| "pending"
|
||||
| "submitted"
|
||||
| "approved"
|
||||
| "adjusted"
|
||||
| "rejected",
|
||||
remark?: string
|
||||
) => {
|
||||
try {
|
||||
await mutate({ id: documentId, data: { status: actionStatus, remark } });
|
||||
toast.success(
|
||||
t("viewDocument.actions.success", { action: actionStatus })
|
||||
);
|
||||
} catch (err: any) {
|
||||
toast.error(
|
||||
t("viewDocument.actions.error", {
|
||||
error: err.message || t("viewDocument.loading.failedToLoad"),
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-4xl mx-auto mt-8 shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader className="border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<FileText className="w-6 h-6 text-primary-600" />
|
||||
{t("viewDocument.title")}
|
||||
</CardTitle>
|
||||
<Button
|
||||
onClick={() => refetch()}
|
||||
variant="default"
|
||||
className="bg-primary-600 hover:bg-primary-700 text-white"
|
||||
disabled={isRefetching}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${isRefetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("viewDocument.refreshButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
{/* User Information Section */}
|
||||
{isUserLoading ? (
|
||||
<div className="mb-6 space-y-4">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
) : isUserError ? (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("viewDocument.failedToLoadUserInfo")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : userInfo ? (
|
||||
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-primary-600" />
|
||||
{t("viewDocument.userInformation")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Building className="w-5 h-5 text-blue-600" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("viewDocument.organizationName")}
|
||||
</p>
|
||||
<p className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{localizedName(userInfo.name)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Mail className="w-5 h-5 text-primary-600" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("viewDocument.email")}
|
||||
</p>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate cursor-help">
|
||||
{truncateEmail(userInfo.email)}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs break-all">{userInfo.email}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Phone className="w-5 h-5 text-purple-600" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("viewDocument.phoneNumber")}
|
||||
</p>
|
||||
<p className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{userInfo.phoneNumber}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Documents Section */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("viewDocument.loading.failedToLoad")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-6 text-lg font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("viewDocument.totalDocuments")}{" "}
|
||||
<span className="font-semibold text-primary-600">
|
||||
{uploadedDocuments?.counts ?? 0}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<ScrollArea className="h-[500px] border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<ul className="space-y-4 p-2">
|
||||
{uploadedDocuments?.items.map((doc, index) => {
|
||||
const eachDocument = documentRequirementsQueries[index]?.data;
|
||||
const isEachLoading =
|
||||
documentRequirementsQueries[index]?.isLoading;
|
||||
const isEachError =
|
||||
documentRequirementsQueries[index]?.isError;
|
||||
const requirements = eachDocument ? [eachDocument] : [];
|
||||
|
||||
// Translate status
|
||||
const getTranslatedStatus = (status: string) => {
|
||||
switch (status) {
|
||||
case "approved":
|
||||
return t("viewDocument.document.status.approved");
|
||||
case "rejected":
|
||||
return t("viewDocument.document.status.rejected");
|
||||
case "pending":
|
||||
default:
|
||||
return t("viewDocument.document.status.pending");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
key={doc.id}
|
||||
className="flex flex-col p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50/50 dark:hover:bg-gray-800/60 transition-colors shadow-sm"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{doc.fileInfo.originalname}
|
||||
</p>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
doc.status === "approved"
|
||||
? "bg-primary-100 text-primary-800"
|
||||
: doc.status === "rejected"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{getTranslatedStatus(doc.status)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-primary-600 hover:bg-primary-700 text-white"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={doc.presigned}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("viewDocument.document.viewDocument")}
|
||||
</a>
|
||||
</Button>
|
||||
{doc.status !== "approved" && (
|
||||
<ActionModal
|
||||
documentId={doc.id}
|
||||
onActionSubmit={handleActionSubmit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document Requirements Section */}
|
||||
<div className="mt-2 border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t("viewDocument.document.requirements")}
|
||||
</h4>
|
||||
{isEachLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
) : isEachError ? (
|
||||
<p className="text-sm text-red-500 dark:text-red-400">
|
||||
{t("viewDocument.loading.failedToLoadRequirements")}
|
||||
</p>
|
||||
) : requirements.length ? (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{requirements.map((req) => (
|
||||
<li key={req.id} className="flex items-start">
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full mt-1.5 mr-2 ${
|
||||
req.isOptional
|
||||
? "bg-blue-400"
|
||||
: "bg-primary-400"
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200">
|
||||
{localizedName(req.title)}
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400 ml-1">
|
||||
{req.isOptional
|
||||
? t("viewDocument.document.optional")
|
||||
: t("viewDocument.document.required")}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
{t("viewDocument.document.noRequirements")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewDocument;
|
||||
@@ -0,0 +1,481 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { ScrollArea } from "@/shared/common/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { Check, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
|
||||
import { useEmployees } from "@/user-management/hooks/useEmployees";
|
||||
import { cn } from "@/super-admin/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../../shared/common/ui/dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
RemoveOrAssignUnitAdminPayload,
|
||||
assignUnitAdminRole,
|
||||
} from "@/super-admin/services/api/userRoleService";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
|
||||
export function AssignAdminDialog({
|
||||
onSuccess,
|
||||
onClose,
|
||||
isOpen,
|
||||
organizationId,
|
||||
unitId,
|
||||
}: {
|
||||
onSuccess: () => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
organizationId?: string;
|
||||
unitId?: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedOrg, setSelectedOrg] = useState<string>(organizationId ?? "");
|
||||
const [selectedUnit, setSelectedUnit] = useState<string>(unitId ?? "");
|
||||
const [selectedAssignmentUnit, setSelectedAssignmentUnit] =
|
||||
useState<string>("");
|
||||
const [selectedUser, setSelectedUser] = useState<string>("");
|
||||
const [isAssigning, setIsAssigning] = useState<boolean>(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const { t } = useTranslation();
|
||||
const [page, setPage] = useState(0);
|
||||
const take = 3000; // items per page
|
||||
const localizedName = useLocalizedName();
|
||||
const hasPreselectedUnit = !!organizationId && !!unitId;
|
||||
const organizationIdForAssignment = organizationId || selectedOrg;
|
||||
const unitIdForAssignment =
|
||||
unitId || selectedUnit || selectedAssignmentUnit;
|
||||
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
|
||||
"Org",
|
||||
{
|
||||
take: 300,
|
||||
}
|
||||
);
|
||||
const { data: unitsResponse, isLoading: isLoadingUnits } = useUnit().getList(
|
||||
organizationIdForAssignment,
|
||||
{ take: 300, skip: 0 },
|
||||
!hasPreselectedUnit
|
||||
);
|
||||
const {
|
||||
employeesResponseByOrg,
|
||||
isLoadingEmployeesByOrg: isLoadingEmployees,
|
||||
refetchEmployeesByOrg,
|
||||
} = useEmployees({
|
||||
organizationId: organizationIdForAssignment || undefined,
|
||||
unitId: unitIdForAssignment || undefined,
|
||||
params: {
|
||||
take,
|
||||
skip: page * take,
|
||||
},
|
||||
});
|
||||
|
||||
// Refetch when unit selection changes
|
||||
useEffect(() => {
|
||||
if (unitIdForAssignment) {
|
||||
refetchEmployeesByOrg();
|
||||
}
|
||||
}, [unitIdForAssignment, refetchEmployeesByOrg]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedOrg && !hasPreselectedUnit) {
|
||||
setSelectedUnit("");
|
||||
setSelectedAssignmentUnit("");
|
||||
setSelectedUser("");
|
||||
setPage(0);
|
||||
}
|
||||
}, [hasPreselectedUnit, selectedOrg]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedUnit && !hasPreselectedUnit) {
|
||||
setSelectedAssignmentUnit("");
|
||||
setSelectedUser("");
|
||||
setPage(0);
|
||||
}
|
||||
}, [hasPreselectedUnit, selectedUnit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
setSelectedOrg(organizationId ?? "");
|
||||
setSelectedUnit(unitId ?? "");
|
||||
setSelectedAssignmentUnit("");
|
||||
setSelectedUser("");
|
||||
setPage(0);
|
||||
setSearch("");
|
||||
}, [isOpen, organizationId, unitId]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (
|
||||
!selectedUser ||
|
||||
!organizationIdForAssignment ||
|
||||
!unitIdForAssignment
|
||||
) {
|
||||
toast.error("All fields are required");
|
||||
return;
|
||||
}
|
||||
|
||||
const user = employeesResponseByOrg?.items.find(
|
||||
(e) => e.user.id === selectedUser
|
||||
);
|
||||
if (!user) return toast.error("User not found");
|
||||
|
||||
setIsAssigning(true);
|
||||
|
||||
const payload: RemoveOrAssignUnitAdminPayload = {
|
||||
unitId: unitIdForAssignment,
|
||||
userId: user.user.id,
|
||||
};
|
||||
|
||||
assignUnitAdminRole(payload)
|
||||
.then(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["organizationAdmins"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["unitAdmins", unitIdForAssignment],
|
||||
});
|
||||
toast.success(t("organization.userAssignedSuccess"));
|
||||
setIsAssigning(false);
|
||||
onClose();
|
||||
onSuccess();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(t("organization.userAssignFailed"), {
|
||||
description: error?.response?.data?.message,
|
||||
});
|
||||
setIsAssigning(false);
|
||||
});
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedOrg(organizationId ?? "");
|
||||
setSelectedUnit(unitId ?? "");
|
||||
setSelectedAssignmentUnit("");
|
||||
setSelectedUser("");
|
||||
setPage(0);
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
const filteredEmployees = useMemo(() => {
|
||||
if (!employeesResponseByOrg?.items) return [];
|
||||
|
||||
return employeesResponseByOrg.items.filter((emp) => {
|
||||
const name = emp.user.name?.en?.toLowerCase() || "";
|
||||
const email = emp.user.email?.toLowerCase() || "";
|
||||
const query = search.toLowerCase();
|
||||
|
||||
return name.includes(query) || email.includes(query);
|
||||
});
|
||||
}, [employeesResponseByOrg, search]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className={hasPreselectedUnit ? "sm:max-w-[550px]" : "sm:max-w-[900px]"}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{hasPreselectedUnit
|
||||
? t("contentManagement.manageUnitAdmin", "Manage Unit Admin")
|
||||
: t("organization.assignAdminToOrganization")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{hasPreselectedUnit
|
||||
? t(
|
||||
"organization.assignUnitAdminInstructions",
|
||||
"Select a user to assign as an administrator for this unit."
|
||||
)
|
||||
: t("organization.assignAdminInstructions")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div
|
||||
className={
|
||||
hasPreselectedUnit
|
||||
? "grid grid-cols-1 gap-4"
|
||||
: "grid grid-cols-3 gap-4"
|
||||
}
|
||||
>
|
||||
{/* Step 1: Organizations */}
|
||||
{!hasPreselectedUnit && <div className="space-y-2">
|
||||
<Label className="font-semibold text-sm">
|
||||
{t("organization.organizations")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
{isLoadingOrgs ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[300px] border border-gray-200 dark:border-gray-700 rounded-md p-2">
|
||||
<div className="space-y-1">
|
||||
{organizationsResponse?.items?.map((org) => (
|
||||
<button
|
||||
key={`org-${org.id}`}
|
||||
onClick={() => setSelectedOrg(org.id)}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2 rounded-md text-left text-sm transition-colors",
|
||||
selectedOrg === org.id
|
||||
? "bg-green-200 text-green-800 dark:bg-green-900/40 dark:text-green-300"
|
||||
: "hover:bg-green-100 dark:hover:bg-green-900/30 text-gray-700 dark:text-gray-300"
|
||||
)}
|
||||
>
|
||||
<span>{localizedName(org.name)}</span>
|
||||
{selectedOrg === org.id && (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{/* Step 2: Units */}
|
||||
{!hasPreselectedUnit && <div className="space-y-2">
|
||||
<Label className="font-semibold text-sm">
|
||||
{t("organization.units")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
{!selectedOrg ? (
|
||||
<div className="h-[300px] border border-gray-200 dark:border-gray-700 rounded-md p-4 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
<p className="text-sm text-center">{t("organization.selectOrganizationFirst")}</p>
|
||||
</div>
|
||||
) : isLoadingUnits ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[300px] border border-gray-200 dark:border-gray-700 rounded-md p-2">
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => setSelectedUnit("")}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2 rounded-md text-left text-sm transition-colors",
|
||||
!selectedUnit
|
||||
? "bg-blue-200 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300"
|
||||
: "hover:bg-blue-100 dark:hover:bg-blue-900/30 text-gray-700 dark:text-gray-300"
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{t(
|
||||
"organization.allOrganizationUsers",
|
||||
"All organization users"
|
||||
)}
|
||||
</span>
|
||||
{!selectedUnit && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
{unitsResponse?.data?.items?.map((unit: any) => (
|
||||
<button
|
||||
key={`unit-${unit.id}`}
|
||||
onClick={() => setSelectedUnit(unit.id)}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2 rounded-md text-left text-sm transition-colors",
|
||||
selectedUnit === unit.id
|
||||
? "bg-blue-200 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300"
|
||||
: "hover:bg-blue-100 dark:hover:bg-blue-900/30 text-gray-700 dark:text-gray-300"
|
||||
)}
|
||||
>
|
||||
<span>{localizedName(unit.name)}</span>
|
||||
{selectedUnit === unit.id && (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{unitsResponse?.data?.items?.length === 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center mt-2">
|
||||
{t("organization.noUnitsFound")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{/* Step 3: Users */}
|
||||
<div className="space-y-2">
|
||||
<Label className="font-semibold text-sm">
|
||||
{t("organization.users")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
{!organizationIdForAssignment ? (
|
||||
<div className="h-[300px] border border-gray-200 dark:border-gray-700 rounded-md p-4 flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
<p className="text-sm text-center">{t("organization.selectOrganizationFirst")}</p>
|
||||
</div>
|
||||
) : isLoadingEmployees ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{!unitIdForAssignment && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
"organization.unitForAdminRole",
|
||||
"Unit for admin role"
|
||||
)}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedAssignmentUnit}
|
||||
onValueChange={setSelectedAssignmentUnit}
|
||||
disabled={isLoadingUnits}
|
||||
>
|
||||
<SelectTrigger className="h-9 border border-gray-200 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-gray-100 text-sm [&>span]:truncate w-full">
|
||||
<SelectValue
|
||||
placeholder={t("organization.selectUnit")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-full min-w-[200px]">
|
||||
{unitsResponse?.data?.items?.map((unit: any) => (
|
||||
<SelectItem key={unit.id} value={unit.id}>
|
||||
<span className="truncate">{localizedName(unit.name)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
placeholder={
|
||||
unitIdForAssignment
|
||||
? t("organization.searchUsers")
|
||||
: t(
|
||||
"organization.searchOrganizationUsers",
|
||||
"Search organization users"
|
||||
)
|
||||
}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-md dark:bg-gray-800 dark:text-gray-100 text-sm"
|
||||
/>
|
||||
{!unitIdForAssignment && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
"organization.selectUnitToFilterUsers",
|
||||
"Select a unit to filter this organization user list."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<ScrollArea className="h-[250px] border border-gray-200 dark:border-gray-700 rounded-md p-2">
|
||||
<div className="space-y-1">
|
||||
{filteredEmployees.map((emp) => (
|
||||
<button
|
||||
key={`emp-${emp.id || emp.user.id}`}
|
||||
onClick={() => setSelectedUser(emp.user.id)}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2 rounded-md text-left text-sm transition-colors",
|
||||
selectedUser === emp.user.id
|
||||
? "bg-purple-200 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300"
|
||||
: "hover:bg-purple-100 dark:hover:bg-purple-900/30 text-gray-700 dark:text-gray-300"
|
||||
)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{emp.user.name?.en || emp.user.email}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{emp.user.email}</p>
|
||||
</div>
|
||||
{selectedUser === emp.user.id && (
|
||||
<Check className="h-4 w-4 ml-2" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{filteredEmployees.length === 0 && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center mt-2">
|
||||
{t("organization.noUsersFound")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between mt-2 px-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((prev) => Math.max(prev - 1, 0))}
|
||||
disabled={page === 0}
|
||||
>
|
||||
{"<"}
|
||||
</Button>
|
||||
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("common.page")} {page + 1}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const total = employeesResponseByOrg?.count ?? 0;
|
||||
const maxPage = Math.ceil(total / take) - 1;
|
||||
setPage((prev) => Math.min(prev + 1, maxPage));
|
||||
}}
|
||||
disabled={
|
||||
!employeesResponseByOrg?.count ||
|
||||
(page + 1) * take >= employeesResponseByOrg.count
|
||||
}
|
||||
>
|
||||
{">"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
onClose();
|
||||
}}
|
||||
disabled={isAssigning}
|
||||
>
|
||||
{t("common.Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isAssigning ||
|
||||
!organizationIdForAssignment ||
|
||||
!unitIdForAssignment ||
|
||||
!selectedUser
|
||||
}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{isAssigning
|
||||
? t("organization.assigning")
|
||||
: t("organization.assignUser")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useOrganizationAdmins } from "@/super-admin/hooks/useOrganizationAdmins";
|
||||
import { AssignedAdminsDto } from "@/shared/dto/organization/orgAdminsDto";
|
||||
import { toast } from "@/shared/common/ui/use-toast";
|
||||
import { useUserRoles } from "@/super-admin/hooks/useUserRoles";
|
||||
import { AdminRemoveConfirm } from "./RemoveAdminConfirm";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { Phone, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
export const OrganizationAdminList = ({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
}) => {
|
||||
const { resendInvitation, getAllAdminById } = useOrganizationAdmins();
|
||||
const { removeUnitAdmin } = useUserRoles();
|
||||
const localizedName = useLocalizedName();
|
||||
|
||||
const [admins, setAdmins] = useState<AssignedAdminsDto[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isResending, setIsResending] = useState<boolean>(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [resendingEmail, setResendingEmail] = useState<string | null>(null);
|
||||
const [take, setTake] = useState(10);
|
||||
const [skip, setSkip] = useState(0);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const pageSize = 10;
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAdmins = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await getAllAdminById(organizationId || "", { take, skip });
|
||||
setAdmins(res?.data?.items || []);
|
||||
|
||||
// Set total count only on first load
|
||||
if (!hasFetched.current && res?.data?.count) {
|
||||
setTotalCount(res.data.count);
|
||||
hasFetched.current = true;
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to fetch admins.",
|
||||
variant: "destructive",
|
||||
});
|
||||
console.error("Fetch admins error:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (organizationId) {
|
||||
fetchAdmins();
|
||||
}
|
||||
}, [take, skip, organizationId]);
|
||||
|
||||
const handleNextPage = () => {
|
||||
const newSkip = skip + pageSize;
|
||||
if (newSkip < totalCount) {
|
||||
setSkip(newSkip);
|
||||
setTake((prev) => prev + pageSize);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (skip > 0) {
|
||||
const newSkip = Math.max(0, skip - pageSize);
|
||||
setSkip(newSkip);
|
||||
setTake((prev) => Math.max(pageSize, prev - pageSize));
|
||||
}
|
||||
};
|
||||
|
||||
const currentPage = Math.floor(skip / pageSize) + 1;
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const hasNextPage = skip + pageSize < totalCount;
|
||||
const hasPrevPage = skip > 0;
|
||||
|
||||
const refetchAdmins = () => {
|
||||
// Reset to first page and refetch
|
||||
setTake(10);
|
||||
setSkip(0);
|
||||
hasFetched.current = false;
|
||||
};
|
||||
|
||||
const handleRemove = async (adminId: string) => {
|
||||
try {
|
||||
setDeletingId(adminId);
|
||||
await removeUnitAdmin({
|
||||
organizationId,
|
||||
userId: adminId,
|
||||
});
|
||||
refetchAdmins();
|
||||
toast({
|
||||
title: "Admin removed successfully.",
|
||||
variant: "success",
|
||||
style: {
|
||||
padding: "8px 16px",
|
||||
minHeight: "36px",
|
||||
color: "var(--primary-800)",
|
||||
backgroundColor: "var(--primary-100)",
|
||||
fontWeight: "600",
|
||||
fontSize: "14px",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to remove admin.",
|
||||
variant: "destructive",
|
||||
});
|
||||
console.error("Remove admin error:", err);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async (admin: AssignedAdminsDto) => {
|
||||
try {
|
||||
setIsResending(true);
|
||||
setResendingEmail(admin.email || null);
|
||||
await resendInvitation({
|
||||
email: admin.email || "",
|
||||
phoneNumber: admin.phoneNumber || "",
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Invitation sent successfully.",
|
||||
variant: "success",
|
||||
style: {
|
||||
padding: "8px 16px",
|
||||
minHeight: "36px",
|
||||
color: "var(--primary-800)",
|
||||
backgroundColor: "var(--primary-100)",
|
||||
fontWeight: "600",
|
||||
fontSize: "14px",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to resend invitation.",
|
||||
variant: "destructive",
|
||||
});
|
||||
console.error("Resend invitation error:", err);
|
||||
} finally {
|
||||
setIsResending(false);
|
||||
setResendingEmail(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-muted-foreground">Loading admins...</p>;
|
||||
}
|
||||
|
||||
if (admins.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No admins assigned.</p>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{admins.map((admin) => (
|
||||
<div
|
||||
key={admin.id}
|
||||
className="rounded-xl p-4 bg-white dark:bg-gray-800 shadow flex justify-between items-center border border-gray-200 dark:border-gray-700 transition hover:bg-primary-50 dark:hover:bg-primary-900/20"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{localizedName(admin?.name) || "Unnamed"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{admin?.email}
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Username: {admin?.username}
|
||||
</p>
|
||||
|
||||
{/* ✅ Fixed phone number UI */}
|
||||
<div className="flex items-center gap-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<Phone className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
<span>{admin?.phoneNumber || "No phone number"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!admin.hasSetPassword && (
|
||||
<Button
|
||||
className="bg-white dark:bg-gray-900 text-primary-600 dark:text-primary-400 border border-primary-600 dark:border-primary-500 hover:bg-primary-600 dark:hover:bg-primary-700 hover:text-white"
|
||||
onClick={() => handleResend(admin)}
|
||||
disabled={isResending && resendingEmail === admin.email}
|
||||
>
|
||||
{isResending && resendingEmail === admin.email
|
||||
? "Sending..."
|
||||
: "Resend"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<AdminRemoveConfirm
|
||||
onConfirm={() => handleRemove(admin.id)}
|
||||
loading={deletingId === admin.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalCount > pageSize && (
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={!hasPrevPage}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Page {currentPage} of {totalPages} ({totalCount} total)
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasNextPage}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
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"));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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;
|
||||
@@ -0,0 +1,77 @@
|
||||
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,59 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface AdminRemoveConfirmProps {
|
||||
onConfirm: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const AdminRemoveConfirm = ({
|
||||
onConfirm,
|
||||
loading,
|
||||
}: AdminRemoveConfirmProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm" disabled={loading}>
|
||||
{t("common.remove")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("organization.removeAdminTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("organization.removeAdminDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{loading
|
||||
? t("organization.removingAdmin")
|
||||
: t("organization.confirmRemoveAdmin")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } from "react";
|
||||
import { Eye } from "lucide-react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { OrganizationAdminList } from "./OrganizationAdminList";
|
||||
|
||||
export const StatusCell = ({
|
||||
id,
|
||||
adminsCount,
|
||||
}: {
|
||||
id: string;
|
||||
adminsCount: number;
|
||||
}) => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium">
|
||||
{adminsCount > 0
|
||||
?
|
||||
`${adminsCount} Admin${adminsCount !== 1 ? "s" : ""}`
|
||||
: "No Admin"}
|
||||
</div>
|
||||
|
||||
{adminsCount > 0 && (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" className="p-1 h-auto w-auto">
|
||||
<Eye className="h-4 w-4 text-gray-500 dark:text-gray-400 hover:text-primary" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assigned Admins</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-y-auto pr-2">
|
||||
<OrganizationAdminList organizationId={id} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/shared/common/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { useOrganizationAdmins } from "@/super-admin/hooks/useOrganizationAdmins";
|
||||
import { OrganizationAdminPayload, UnitAdminPayload } from "@/super-admin/services/api/organizationAdminService";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { t } from "i18next";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
|
||||
const simplifiedSchema = z.object({
|
||||
name: z.object({
|
||||
am: z.string().min(1, t("organization.amharicNameRequired")),
|
||||
en: z.string().min(1, t("organization.englishNameRequired")),
|
||||
}),
|
||||
email: z.string().email(t("organization.invalidEmail")),
|
||||
username: z.string().min(3, t("organization.usernameMinLength")),
|
||||
unitId: z.string().min(1, t("organization.organizationRequired")),
|
||||
phone: z
|
||||
.string()
|
||||
.regex(/^(\+251|0)?9\d{8}$/, t("organization.invalidPhoneNumber")),
|
||||
});
|
||||
|
||||
type SimplifiedFormValues = z.infer<typeof simplifiedSchema>;
|
||||
|
||||
export const AdminRegistrationForm = () => {
|
||||
const navigate = useNavigate();
|
||||
const localizedName = useLocalizedName();
|
||||
|
||||
const { organizationsResponse } = useOrganizations("Org", {
|
||||
take: 3000,
|
||||
});
|
||||
const { createUnitAdmin, isCreating } = useOrganizationAdmins();
|
||||
const { user } = useAuth();
|
||||
|
||||
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string>("");
|
||||
const { data: unitsResponse, isLoading: isLoadingUnits } = useUnit().getList(
|
||||
selectedOrgId || "",
|
||||
{ take: 300, skip: 0 }
|
||||
);
|
||||
|
||||
// Add state for selected unitId
|
||||
// Default: if super_admin => "All", otherwise wait for units
|
||||
const [selectedUnitId, setSelectedUnitId] = useState<string>("All");
|
||||
|
||||
useEffect(() => {
|
||||
// If there’s no selectedUnitId yet, default to first unit (if any), otherwise keep "All"
|
||||
if (!selectedUnitId) {
|
||||
if (unitsResponse?.data?.items?.length) {
|
||||
setSelectedUnitId(unitsResponse.data.items[0].id);
|
||||
} else {
|
||||
setSelectedUnitId("All");
|
||||
}
|
||||
}
|
||||
}, [unitsResponse, selectedUnitId]);
|
||||
|
||||
const form = useForm<SimplifiedFormValues>({
|
||||
resolver: zodResolver(simplifiedSchema),
|
||||
defaultValues: {
|
||||
name: { am: "", en: "" },
|
||||
email: "",
|
||||
username: "",
|
||||
unitId: "",
|
||||
phone: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: SimplifiedFormValues) => {
|
||||
const payload: UnitAdminPayload = {
|
||||
email: values.email,
|
||||
name: values.name,
|
||||
unitId: values.unitId,
|
||||
username: values.username,
|
||||
phoneNumber: values.phone,
|
||||
};
|
||||
createUnitAdmin({
|
||||
payload,
|
||||
successCallback: () => {
|
||||
navigate("/user-management/organization_admins");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="max-w-2xl mx-auto shadow-md border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-semibold text-gray-800 dark:text-gray-100">
|
||||
{t("organization.registerAdminTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-500 dark:text-gray-400">
|
||||
{t("organization.registerAdminDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name.en">
|
||||
{t("organization.nameEnglish")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name.en"
|
||||
{...form.register("name.en")}
|
||||
placeholder={t("organization.enterEnglishName")}
|
||||
/>
|
||||
{form.formState.errors.name?.en && (
|
||||
<p className="text-red-500 text-xs">
|
||||
{form.formState.errors.name.en.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name.am">
|
||||
{t("organization.nameAmharic")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name.am"
|
||||
{...form.register("name.am")}
|
||||
placeholder={t("organization.enterAmharicName")}
|
||||
/>
|
||||
{form.formState.errors.name?.am && (
|
||||
<p className="text-red-500 text-xs">
|
||||
{form.formState.errors.name.am.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
{t("organization.email")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
{...form.register("email")}
|
||||
placeholder={t("organization.emailExample")}
|
||||
/>
|
||||
{form.formState.errors.email && (
|
||||
<p className="text-red-500 text-xs">
|
||||
{form.formState.errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">
|
||||
{t("organization.username")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
{...form.register("username")}
|
||||
placeholder={t("organization.enterUsername")}
|
||||
/>
|
||||
{form.formState.errors.username && (
|
||||
<p className="text-red-500 text-xs">
|
||||
{form.formState.errors.username.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">
|
||||
{t("organization.phoneNumber")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
{...form.register("phone")}
|
||||
placeholder={t("organization.phoneNumberExample")}
|
||||
/>
|
||||
{form.formState.errors.phone && (
|
||||
<p className="text-red-500 text-xs">
|
||||
{form.formState.errors.phone.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 ">
|
||||
<Label htmlFor="organizationId">
|
||||
{t("organization.organization")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedOrgId}
|
||||
onValueChange={(val: string) => {
|
||||
setSelectedOrgId(val);
|
||||
form.setValue("unitId", ""); // reset unit when org changes
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("organization.selectOrganization")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{organizationsResponse?.items
|
||||
.filter((org) => org.status === "Active")
|
||||
.map((org) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
{localizedName(org.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.unitId && (
|
||||
<p className="text-red-500 text-xs">
|
||||
{form.formState.errors.unitId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={form.watch("unitId")}
|
||||
onValueChange={(val: string) => form.setValue("unitId", val)}
|
||||
disabled={!selectedOrgId || isLoadingUnits}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("organization.selectUnit")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{unitsResponse?.data?.items?.map((unit:any) => (
|
||||
<SelectItem key={unit.id} value={unit.id}>
|
||||
{localizedName(unit.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.unitId && (
|
||||
<p className="text-red-500 text-xs">
|
||||
{form.formState.errors.unitId.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isCreating}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground">
|
||||
{isCreating
|
||||
? t("organization.registering")
|
||||
: t("organization.registerAdmin")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { t } from "i18next";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface DeleteOrganizationPopupProps {
|
||||
organizationName: string;
|
||||
onDelete: () => void;
|
||||
onCancel: () => void;
|
||||
isLoading: boolean;
|
||||
trigger: ReactNode;
|
||||
}
|
||||
|
||||
export const DeleteOrganizationPopup = ({
|
||||
organizationName,
|
||||
onDelete,
|
||||
onCancel,
|
||||
isLoading,
|
||||
trigger,
|
||||
}: DeleteOrganizationPopupProps) => {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("organization.deleteOrganization")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("organization.confirmDelete")}{" "}
|
||||
<strong>{organizationName}</strong>? {t("organization.cannotUndo")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{t("common.Cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700 text-white cursor-pointer"
|
||||
disabled={isLoading}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{isLoading ? t("organization.deleting") : t("organization.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
import { OrganizationPayload } from "@/shared/services/organizationsService";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||
import OrganizationForm, { FormValues } from "./common/OrganizationForm";
|
||||
|
||||
interface EditOrganizationFormProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const EditOrganizationForm: React.FC<EditOrganizationFormProps> = ({
|
||||
id,
|
||||
}) => {
|
||||
const [organizationDetails, setOrganizationDetails] =
|
||||
useState<OrganizationDto>();
|
||||
const { editOrganization, isEditing, getOrganizationByDetails } =
|
||||
useOrganizations("Org");
|
||||
|
||||
const fetchOrganizationDetails = () => {
|
||||
getOrganizationByDetails(id, {
|
||||
onSuccess: (organization: OrganizationDto) => {
|
||||
setOrganizationDetails(organization);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrganizationDetails();
|
||||
}, []);
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
const requestData: OrganizationPayload = {
|
||||
key: values.key,
|
||||
name: values.name,
|
||||
organizationTypeId: values.organizationTypeId,
|
||||
isGovernmentOrganization: values.isGovernmentOrganization,
|
||||
};
|
||||
if (values.parentId && values.parentId !== "") {
|
||||
requestData["parentId"] = values.parentId;
|
||||
}
|
||||
editOrganization({ id, payload: requestData });
|
||||
};
|
||||
|
||||
return (
|
||||
<OrganizationForm
|
||||
isLoading={isEditing}
|
||||
onSubmit={(values: FormValues) => onSubmit(values)}
|
||||
type="Edit"
|
||||
organizationDetails={organizationDetails}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,597 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerClose,
|
||||
} from "@/shared/common/ui/drawer";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useCreateGlobalUnitConfig,
|
||||
useGetListOfGlobalUnitConfig,
|
||||
useUpdateGlobalUnitConfig,
|
||||
useDeleteGlobalUnitConfig,
|
||||
useGetOrganizationConfig,
|
||||
useCreateOrganizationConfig,
|
||||
useUpdateOrganizationConfig,
|
||||
useDeleteOrganizationConfig,
|
||||
} from "@/super-admin/hooks/useConfig";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Loader2, Trash2 } from "lucide-react";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { RadioGroup, RadioGroupItem } from "@/shared/common/ui/radio-group";
|
||||
import { Switch } from "@/shared/common/ui/switch";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import { prefixSuffixService } from "@/user-management/services/api/prefixSuffixService";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
export default function ManageOrganizationDrawer({
|
||||
manageOrgConfigOpen,
|
||||
setManageOrgConfigOpen,
|
||||
organizationId,
|
||||
}: any) {
|
||||
const { getList } = useUnit();
|
||||
const [unit, setUnit] = useState("");
|
||||
const [maxUnits, setMaxUnits] = useState("");
|
||||
const [employees, setEmployees] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [canReceiveComplaint, setCanReceiveComplaint] = useState(false);
|
||||
const [unitConfigurationId, setUnitConfigurationId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data: unitsResponse } = organizationId
|
||||
? getList(organizationId, {
|
||||
take: 300,
|
||||
skip: 0,
|
||||
})
|
||||
: { data: undefined };
|
||||
const localizedName = useLocalizedName();
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
const unitConfigQuery = useGetListOfGlobalUnitConfig(organizationId);
|
||||
const orgConfigQuery = useGetOrganizationConfig(organizationId);
|
||||
const [isUnitConfig, setIsUnitConfig] = useState(false);
|
||||
|
||||
const listData = isUnitConfig ? unitConfigQuery.data : orgConfigQuery.data;
|
||||
const isLoading = isUnitConfig
|
||||
? unitConfigQuery.isLoading
|
||||
: orgConfigQuery.isLoading;
|
||||
const refetch = isUnitConfig
|
||||
? unitConfigQuery.refetch
|
||||
: orgConfigQuery.refetch;
|
||||
|
||||
const createUnitMutation = useCreateGlobalUnitConfig();
|
||||
const updateUnitMutation = useUpdateGlobalUnitConfig();
|
||||
const deleteUnitMutation = useDeleteGlobalUnitConfig();
|
||||
const createOrgMutation = useCreateOrganizationConfig();
|
||||
const updateOrgMutation = useUpdateOrganizationConfig();
|
||||
const deleteOrgMutation = useDeleteOrganizationConfig();
|
||||
|
||||
const isSubmitting = isUnitConfig
|
||||
? createUnitMutation.isPending || updateUnitMutation.isPending
|
||||
: createOrgMutation.isPending || updateOrgMutation.isPending;
|
||||
const deleteMutation = isUnitConfig ? deleteUnitMutation : deleteOrgMutation;
|
||||
|
||||
// Org config is 1-per-organization. Auto-load the existing record into edit
|
||||
// mode so submit becomes PUT instead of POST.
|
||||
const existingOrgConfig = (() => {
|
||||
const items = orgConfigQuery.data?.data?.items ?? [];
|
||||
if (!Array.isArray(items)) return null;
|
||||
return (
|
||||
items.find(
|
||||
(it: any) =>
|
||||
(it?.organizationId ?? it?.organization_id) === organizationId,
|
||||
) ??
|
||||
items[0] ??
|
||||
null
|
||||
);
|
||||
})();
|
||||
|
||||
// Unit config is 1-per-unit. Look up by the selected unit.
|
||||
const existingUnitConfig = (() => {
|
||||
if (!unit) return null;
|
||||
const items = unitConfigQuery.data?.data?.items ?? [];
|
||||
if (!Array.isArray(items)) return null;
|
||||
return (
|
||||
items.find((it: any) => (it?.unitId ?? it?.unit_id) === unit) ?? null
|
||||
);
|
||||
})();
|
||||
|
||||
const singleUnitId =
|
||||
isUnitConfig &&
|
||||
unitsResponse?.data?.items &&
|
||||
Array.isArray(unitsResponse.data.items) &&
|
||||
unitsResponse.data.items.length === 1
|
||||
? unitsResponse.data.items[0].id
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!singleUnitId) return;
|
||||
if (unit) return; // keep user selection if already set
|
||||
setUnit(singleUnitId);
|
||||
}, [isUnitConfig, singleUnitId, unit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isUnitConfig) return;
|
||||
if (existingOrgConfig?.id && editingId !== existingOrgConfig.id) {
|
||||
setEditingId(existingOrgConfig.id);
|
||||
setMaxUnits((existingOrgConfig.maximumNumberOfUnits ?? 0).toString());
|
||||
}
|
||||
}, [isUnitConfig, existingOrgConfig, editingId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUnitConfig) return;
|
||||
if (existingUnitConfig?.id && editingId !== existingUnitConfig.id) {
|
||||
setEditingId(existingUnitConfig.id);
|
||||
setMaxUnits((existingUnitConfig.numberOfSubUnits ?? 0).toString());
|
||||
setEmployees(
|
||||
(existingUnitConfig.numberOfEmployeesPerUnit ?? 0).toString(),
|
||||
);
|
||||
}
|
||||
}, [isUnitConfig, existingUnitConfig, editingId]);
|
||||
|
||||
const handleAddOrUpdate = () => {
|
||||
if (isUnitConfig && !unit) {
|
||||
toast.warning(t("superAdmin.messages.selectUnitWarning"));
|
||||
return;
|
||||
}
|
||||
if (isUnitConfig ? !maxUnits || !employees : !maxUnits) {
|
||||
toast.warning(t("superAdmin.messages.fillAllFields"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUnitConfig) {
|
||||
const payload = {
|
||||
unitId: unit,
|
||||
numberOfSubUnits: Number(maxUnits),
|
||||
numberOfEmployeesPerUnit: Number(employees),
|
||||
};
|
||||
const id = editingId || existingUnitConfig?.id || null;
|
||||
if (id) {
|
||||
updateUnitMutation.mutate(
|
||||
{ id, data: payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("superAdmin.messages.configUpdated"));
|
||||
resetForm();
|
||||
refetch();
|
||||
},
|
||||
onError: handleError,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
createUnitMutation.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
toast.success(t("superAdmin.messages.configAdded"));
|
||||
resetForm();
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => handleDuplicateOrError(error),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Org config — preserve other fields (e.g. canStartReceivingRecord) from
|
||||
// the existing record so we don't clobber the visibility toggle.
|
||||
const payload = {
|
||||
organizationId,
|
||||
maximumNumberOfUnits: Number(maxUnits),
|
||||
...(existingOrgConfig?.canStartReceivingRecord !== undefined && {
|
||||
canStartReceivingRecord: existingOrgConfig.canStartReceivingRecord,
|
||||
}),
|
||||
...(existingOrgConfig?.canCreateBranchByItself !== undefined && {
|
||||
canCreateBranchByItself: existingOrgConfig.canCreateBranchByItself,
|
||||
}),
|
||||
};
|
||||
const id = editingId || existingOrgConfig?.id || null;
|
||||
if (id) {
|
||||
updateOrgMutation.mutate(
|
||||
{ id, data: payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("superAdmin.messages.configUpdated"));
|
||||
resetForm();
|
||||
refetch();
|
||||
},
|
||||
onError: handleError,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
createOrgMutation.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
toast.success(t("superAdmin.messages.configAdded"));
|
||||
resetForm();
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => handleDuplicateOrError(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateOrError = (error: any) => {
|
||||
const status = error?.response?.status;
|
||||
const message: string =
|
||||
error?.response?.data?.message ?? error?.message ?? "";
|
||||
if (status === 400 && /duplicate/i.test(message)) {
|
||||
toast.warning(t("superAdmin.messages.configAlreadyExists"));
|
||||
refetch();
|
||||
return;
|
||||
}
|
||||
handleError(error);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setUnit("");
|
||||
setMaxUnits("");
|
||||
setEmployees("");
|
||||
setEditingId(null);
|
||||
setCanReceiveComplaint(false);
|
||||
setUnitConfigurationId(null);
|
||||
};
|
||||
|
||||
const handleEdit = (config: any) => {
|
||||
if (isUnitConfig) {
|
||||
setEditingId(config.id);
|
||||
setUnit(config.unitId ?? "");
|
||||
setMaxUnits((config.numberOfSubUnits ?? 0).toString());
|
||||
setEmployees((config.numberOfEmployeesPerUnit ?? 0).toString());
|
||||
} else {
|
||||
setEditingId(config.id);
|
||||
setMaxUnits((config.maximumNumberOfUnits ?? 0).toString());
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (config: any) => {
|
||||
if (!config?.id) return;
|
||||
deleteMutation.mutate(config.id, {
|
||||
onSuccess: () => {
|
||||
toast.success(t("superAdmin.messages.configDeleted"));
|
||||
if (editingId === config.id) resetForm();
|
||||
refetch();
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
};
|
||||
|
||||
const configType = isUnitConfig
|
||||
? t("superAdmin.manageConfig.unit")
|
||||
: t("superAdmin.manageConfig.organization");
|
||||
|
||||
const configLevel = isUnitConfig
|
||||
? t("superAdmin.manageConfig.unitBased")
|
||||
: t("superAdmin.manageConfig.organizationLevel");
|
||||
|
||||
const internalUnitConfigQuery = useQuery({
|
||||
queryKey: ["internal-unit-config", unit],
|
||||
queryFn: () => prefixSuffixService.getInternalConfig(unit),
|
||||
enabled: isUnitConfig && Boolean(unit),
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUnitConfig || !unit) {
|
||||
setUnitConfigurationId(null);
|
||||
setCanReceiveComplaint(false);
|
||||
return;
|
||||
}
|
||||
const internalConfig = internalUnitConfigQuery.data?.data?.items?.[0];
|
||||
if (!internalConfig?.id) {
|
||||
setUnitConfigurationId(null);
|
||||
setCanReceiveComplaint(false);
|
||||
return;
|
||||
}
|
||||
setUnitConfigurationId(internalConfig.id);
|
||||
setCanReceiveComplaint(Boolean(internalConfig.canReceiveComplaint));
|
||||
}, [internalUnitConfigQuery.data, isUnitConfig, unit]);
|
||||
|
||||
const { mutate: updateCanReceiveComplaint, isPending: isUpdatingComplaint } =
|
||||
useMutation({
|
||||
mutationFn: async (checked: boolean) => {
|
||||
if (!unit) throw new Error("Missing unit id");
|
||||
|
||||
// If no internal unit config exists yet, create it first.
|
||||
if (!unitConfigurationId) {
|
||||
await prefixSuffixService.createInternalPrefixSuffix({
|
||||
unitId: unit,
|
||||
});
|
||||
}
|
||||
|
||||
const current = await prefixSuffixService.getInternalConfig(unit);
|
||||
const internalConfig = current.data?.items?.[0];
|
||||
if (!internalConfig?.id) {
|
||||
throw new Error("Failed to resolve unit configuration id");
|
||||
}
|
||||
|
||||
return prefixSuffixService.updateInternalPrefixSuffix(
|
||||
{
|
||||
canReceiveComplaint: checked,
|
||||
unitId: unit,
|
||||
},
|
||||
internalConfig.id,
|
||||
);
|
||||
},
|
||||
onSuccess: (_data, checked) => {
|
||||
setCanReceiveComplaint(checked);
|
||||
internalUnitConfigQuery.refetch();
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error(err);
|
||||
handleError(err);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Drawer open={manageOrgConfigOpen} onOpenChange={setManageOrgConfigOpen}>
|
||||
<DrawerContent className="max-h-[85vh] overflow-hidden flex flex-col top-1/6 bg-white dark:bg-gray-900">
|
||||
<div className="overflow-y-auto flex-1">
|
||||
<DrawerHeader className="pb-4">
|
||||
<DrawerTitle className="text-lg text-primary-700 dark:text-primary-400">
|
||||
{t("superAdmin.manageConfig.title", { type: configType })}
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="text-primary-600/80 dark:text-primary-300/80 space-y-3">
|
||||
<div>
|
||||
{t("superAdmin.manageConfig.description", {
|
||||
level: configLevel,
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Radio Group for Organization/Unit selection */}
|
||||
<RadioGroup
|
||||
value={isUnitConfig ? "unit" : "org"}
|
||||
onValueChange={(value) => {
|
||||
setIsUnitConfig(value === "unit");
|
||||
resetForm();
|
||||
}}
|
||||
className="inline-flex rounded-md bg-gray-100 dark:bg-gray-800 p-1">
|
||||
<RadioGroupItem value="org" id="org-radio" className="hidden" />
|
||||
<label
|
||||
htmlFor="org-radio"
|
||||
className={`cursor-pointer px-4 py-1 rounded-md text-sm font-medium transition-all
|
||||
${
|
||||
!isUnitConfig
|
||||
? "bg-primary-600 text-white"
|
||||
: "text-primary-700 dark:text-primary-300 hover:bg-primary-200 dark:hover:bg-primary-900/30"
|
||||
}`}>
|
||||
{t("superAdmin.manageConfig.organization")}
|
||||
</label>
|
||||
|
||||
<RadioGroupItem
|
||||
value="unit"
|
||||
id="unit-radio"
|
||||
className="hidden"
|
||||
/>
|
||||
<label
|
||||
htmlFor="unit-radio"
|
||||
className={`cursor-pointer px-4 py-1 rounded-md text-sm font-medium transition-all
|
||||
${
|
||||
isUnitConfig
|
||||
? "bg-primary-600 text-white"
|
||||
: "text-primary-700 dark:text-primary-300 hover:bg-primary-200 dark:hover:bg-primary-900/30"
|
||||
}`}>
|
||||
{t("superAdmin.manageConfig.unit")}
|
||||
</label>
|
||||
</RadioGroup>
|
||||
|
||||
{/* Unit Selector */}
|
||||
{isUnitConfig && unitsResponse?.data?.items?.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<label className="block text-sm font-medium text-primary-700 dark:text-primary-300">
|
||||
{t("superAdmin.manageConfig.selectUnit")}
|
||||
</label>
|
||||
<Select
|
||||
value={unit}
|
||||
onValueChange={(value) => setUnit(value)}>
|
||||
<SelectTrigger className="mt-1 border-primary-400 dark:border-primary-700 dark:bg-gray-800 dark:text-gray-100 focus:ring-primary-500">
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"superAdmin.manageConfig.selectUnitPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{unitsResponse?.data.items.map((u: UnitDto) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{localizedName(u.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="px-6 space-y-4 pb-4">
|
||||
{/* Input Fields */}
|
||||
<div
|
||||
className={`grid gap-4 ${
|
||||
isUnitConfig ? "grid-cols-2" : "grid-cols-1"
|
||||
}`}>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-primary-700 dark:text-primary-300">
|
||||
{isUnitConfig
|
||||
? t("superAdmin.manageConfig.subOrganizations")
|
||||
: t(
|
||||
"superAdmin.manageConfig.maxUnits",
|
||||
"Maximum Number of Units",
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={maxUnits}
|
||||
onChange={(e) => setMaxUnits(e.target.value)}
|
||||
placeholder={t("superAdmin.manageConfig.subOrgsPlaceholder")}
|
||||
className="border-primary-400 dark:border-primary-700 dark:bg-gray-800 dark:text-gray-100 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isUnitConfig && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-primary-700 dark:text-primary-300">
|
||||
{t("superAdmin.manageConfig.employeesPerOrg")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={employees}
|
||||
onChange={(e) => setEmployees(e.target.value)}
|
||||
placeholder={t(
|
||||
"superAdmin.manageConfig.employeesPlaceholder",
|
||||
)}
|
||||
className="border-primary-400 dark:border-primary-700 dark:bg-gray-800 dark:text-gray-100 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUnitConfig && (
|
||||
<div className="col-span-2 mt-1">
|
||||
<div className="flex items-center justify-between rounded-lg border border-primary-200 bg-primary-50/40 px-4 py-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-primary-700 dark:text-primary-300">
|
||||
{t(
|
||||
"superAdmin.manageConfig.canReceiveComplaint",
|
||||
"Can Receive Complaint",
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-primary-600/70">
|
||||
{t(
|
||||
"superAdmin.manageConfig.canReceiveComplaintDescription",
|
||||
"Controls whether this unit can accept complaints.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={canReceiveComplaint}
|
||||
onCheckedChange={(checked) =>
|
||||
updateCanReceiveComplaint(checked)
|
||||
}
|
||||
disabled={!unit || isUpdatingComplaint}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add / Update Button */}
|
||||
<Button
|
||||
onClick={handleAddOrUpdate}
|
||||
className="w-full flex items-center gap-2 bg-primary-600 hover:bg-primary-700 text-white"
|
||||
disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{editingId ||
|
||||
(!isUnitConfig && existingOrgConfig?.id) ||
|
||||
(isUnitConfig && existingUnitConfig?.id)
|
||||
? t("superAdmin.manageConfig.updateConfiguration")
|
||||
: t("superAdmin.manageConfig.addConfiguration")}
|
||||
</Button>
|
||||
|
||||
{/* Configurations List */}
|
||||
<div className="border border-primary-300 dark:border-primary-800 rounded-lg p-3 space-y-3 mt-4">
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-primary-600/70 text-center py-4">
|
||||
{t("superAdmin.manageConfig.loading")}
|
||||
</p>
|
||||
) : listData?.data?.items?.length ? (
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{listData.data.items.map((c: any) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex items-center justify-between border-b border-primary-100 pb-2 last:border-none py-2">
|
||||
<div>
|
||||
{isUnitConfig && (
|
||||
<p className="font-semibold capitalize text-primary-700 text-sm">
|
||||
{localizedName(
|
||||
unitsResponse?.data?.items?.find(
|
||||
(u: UnitDto) => u.id === c.unitId,
|
||||
)?.name,
|
||||
) || c.unitId}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-primary-600/80">
|
||||
{isUnitConfig ? (
|
||||
<>
|
||||
{c.numberOfSubUnits}{" "}
|
||||
{t("superAdmin.manageConfig.subOrganizations")} •{" "}
|
||||
{c.numberOfEmployeesPerUnit}{" "}
|
||||
{t("superAdmin.manageConfig.employeesPerOrg")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t(
|
||||
"superAdmin.manageConfig.maxUnits",
|
||||
"Maximum Number of Units",
|
||||
)}
|
||||
: {c.maximumNumberOfUnits ?? 0}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(c)}
|
||||
className="h-8 px-2 text-xs text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/30">
|
||||
{t("superAdmin.manageConfig.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(c)}
|
||||
disabled={
|
||||
deleteMutation.isPending &&
|
||||
deleteMutation.variables === c.id
|
||||
}
|
||||
className="h-8 w-8 p-0 text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/30">
|
||||
{deleteMutation.isPending &&
|
||||
deleteMutation.variables === c.id ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-primary-600/70 text-center py-4">
|
||||
{t("superAdmin.manageConfig.noConfigurations")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerFooter className="pt-4 border-t border-primary-200 dark:border-primary-800 bg-primary-50/40 dark:bg-primary-900/10">
|
||||
<DrawerClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={resetForm}
|
||||
className="w-full border-primary-500 dark:border-primary-700 text-primary-700 dark:text-primary-300 hover:bg-primary-100 dark:hover:bg-primary-900/30">
|
||||
{t("superAdmin.manageConfig.close")}
|
||||
</Button>
|
||||
</DrawerClose>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
import { OrganizationPayload } from "@/shared/services/organizationsService";
|
||||
|
||||
import OrganizationForm, { FormValues } from "./common/OrganizationForm";
|
||||
|
||||
export const NewOrganizationForm = () => {
|
||||
const { createOrganization, isCreating } = useOrganizations("Org");
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
const requestData: OrganizationPayload = {
|
||||
key: values.key,
|
||||
name: values.name,
|
||||
organizationTypeId: values.organizationTypeId,
|
||||
isGovernmentOrganization: values.isGovernmentOrganization,
|
||||
};
|
||||
if (values.parentId && values.parentId !== "") {
|
||||
requestData["parentId"] = values.parentId;
|
||||
}
|
||||
createOrganization(requestData);
|
||||
};
|
||||
|
||||
return (
|
||||
<OrganizationForm
|
||||
isLoading={isCreating}
|
||||
onSubmit={(values: FormValues) => onSubmit(values)}
|
||||
type="Create"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { SelectItem } from "@/shared/common/ui/select";
|
||||
import { Separator } from "@/shared/common/ui/separator";
|
||||
import { useOrganizationDetail } from "@/super-admin/hooks/useOrganizations";
|
||||
import { useOrganizationTypes } from "@/super-admin/hooks/useOrganizationTypes";
|
||||
import { Building, Building2, Home, MapPin, ShieldCheck } from "lucide-react";
|
||||
|
||||
interface OrganizationCardProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const OrganizationCard: React.FC<OrganizationCardProps> = ({ id }) => {
|
||||
const localized = useLocalizedName();
|
||||
const { organizationsDetailResponse, isDetailLoading, isDetailError } =
|
||||
useOrganizationDetail("Org", id);
|
||||
const {
|
||||
organizationTypesResponse,
|
||||
isLoading: isLoadingOrgTypes,
|
||||
isError: orgTypesError,
|
||||
} = useOrganizationTypes();
|
||||
|
||||
if (isDetailLoading) return <div>Loading...</div>;
|
||||
if (isDetailError || !organizationsDetailResponse)
|
||||
return <div>Error loading organization.</div>;
|
||||
|
||||
const org = organizationsDetailResponse.items;
|
||||
const orgType = org.organizationTypeId;
|
||||
|
||||
const formatDate = (dateStr: string) =>
|
||||
new Date(dateStr).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-3xl mx-auto shadow-xl rounded-2xl border border-gray-200 bg-white dark:bg-zinc-900 transition hover:shadow-2xl">
|
||||
<CardHeader className="flex items-center gap-3 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<Building2 className="w-7 h-7 text-primary" />
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
{localized(org.name)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Status & Type Badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Government / Private */}
|
||||
<Badge
|
||||
variant={org.isGovernmentOrganization ? "default" : "outline"}
|
||||
className={`px-3 py-1 rounded-xl ${
|
||||
org.isGovernmentOrganization
|
||||
? "bg-primary-100 text-primary-800 dark:bg-primary-800 dark:text-primary-100"
|
||||
: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100"
|
||||
}`}>
|
||||
{org.isGovernmentOrganization ? "Government" : "Private"}
|
||||
</Badge>
|
||||
|
||||
{/* Status: Active / Debarred */}
|
||||
<Badge
|
||||
variant="default"
|
||||
className={`px-3 py-1 rounded-xl ${
|
||||
org.status === "Active"
|
||||
? "bg-primary-100 text-primary-800 dark:bg-primary-800 dark:text-primary-100"
|
||||
: "bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100"
|
||||
}`}>
|
||||
{org.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
{/* Key Details */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
{/* Created At */}
|
||||
<div className="order-1">
|
||||
<p className="font-semibold">Created At:</p>
|
||||
<p>{formatDate(org.createdAt)}</p>
|
||||
</div>
|
||||
|
||||
{/* Updated At */}
|
||||
<div className="order-2">
|
||||
<p className="font-semibold">Updated At:</p>
|
||||
<p>{formatDate(org.updatedAt)}</p>
|
||||
</div>
|
||||
|
||||
{/* Key spans full width and comes last */}
|
||||
<div className="sm:col-span-2 order-3">
|
||||
<p className="font-semibold">Key:</p>
|
||||
<p className="break-words">{org.key}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{orgType &&
|
||||
organizationTypesResponse?.items
|
||||
?.filter((type) => type.id === orgType)
|
||||
.map((type) => {
|
||||
let Icon;
|
||||
let bgColor;
|
||||
let textColor;
|
||||
|
||||
switch (type.key) {
|
||||
case "super_admin":
|
||||
Icon = ShieldCheck;
|
||||
bgColor = "bg-purple-100 dark:bg-purple-800";
|
||||
textColor = "text-purple-800 dark:text-purple-100";
|
||||
break;
|
||||
case "woreda":
|
||||
Icon = MapPin;
|
||||
bgColor = "bg-blue-100 dark:bg-blue-800";
|
||||
textColor = "text-blue-800 dark:text-blue-100";
|
||||
break;
|
||||
case "subcity":
|
||||
Icon = Home;
|
||||
bgColor = "bg-primary-100 dark:bg-primary-800";
|
||||
textColor = "text-primary-800 dark:text-primary-100";
|
||||
break;
|
||||
case "office":
|
||||
Icon = Building;
|
||||
bgColor = "bg-yellow-100 dark:bg-yellow-800";
|
||||
textColor = "text-yellow-800 dark:text-yellow-100";
|
||||
break;
|
||||
default:
|
||||
Icon = Building;
|
||||
bgColor = "bg-gray-100 dark:bg-gray-800";
|
||||
textColor = "text-gray-800 dark:text-gray-100";
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={type.id}
|
||||
className={`flex items-center gap-1 px-3 py-1 rounded-xl font-medium ${bgColor} ${textColor}`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
{localized(type.name)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { AdvancedTable } from "../../../shared/common/ui/table/AdvancedTable";
|
||||
import { OrganizationsColumnDefn } from "./OrganizationsColumnDefn";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Loader, Plus, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/common/ui/card";
|
||||
import { WithPermission } from "@/shared/hooks/useHas";
|
||||
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
|
||||
export default function Organizations() {
|
||||
const [pageIndex, setPageIndex] = useState(0); // starts at 0
|
||||
const [expandedParents, setExpandedParents] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const pageSize = 10; // 10 organizations per page
|
||||
|
||||
// Reset to first page whenever the search term changes so users land on
|
||||
// the first page of matches instead of an empty later page.
|
||||
useEffect(() => {
|
||||
setPageIndex(0);
|
||||
}, [searchTerm]);
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
const { organizationsResponse, isLoading, refetch } = useOrganizations(
|
||||
"Org",
|
||||
{
|
||||
take: 1000, // Get all organizations to build hierarchy
|
||||
skip: 0,
|
||||
orderBy: "createdAt",
|
||||
order: "createdAt:Desc",
|
||||
}
|
||||
);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPageIndex(newPage);
|
||||
};
|
||||
|
||||
const toggleExpanded = (parentId: string) => {
|
||||
setExpandedParents((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(parentId)) {
|
||||
newSet.delete(parentId);
|
||||
} else {
|
||||
newSet.add(parentId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Create a map of organizations for quick parent lookup
|
||||
const organizationMap = useMemo(() => {
|
||||
const map = new Map<string, OrganizationDto>();
|
||||
(organizationsResponse?.items || []).forEach((org) => {
|
||||
map.set(org.id, org);
|
||||
});
|
||||
return map;
|
||||
}, [organizationsResponse?.items]);
|
||||
|
||||
// Build hierarchical structure for dropdown display
|
||||
const buildHierarchy = (orgs: OrganizationDto[]) => {
|
||||
const root: OrganizationDto[] = [];
|
||||
const children = new Map<string, OrganizationDto[]>();
|
||||
|
||||
// Separate root and children
|
||||
orgs.forEach((org) => {
|
||||
if (org.parentId) {
|
||||
if (!children.has(org.parentId)) {
|
||||
children.set(org.parentId, []);
|
||||
}
|
||||
children.get(org.parentId)!.push(org);
|
||||
} else {
|
||||
root.push(org);
|
||||
}
|
||||
});
|
||||
|
||||
return { root, children };
|
||||
};
|
||||
|
||||
const allOrganizations = useMemo(
|
||||
() => organizationsResponse?.items || [],
|
||||
[organizationsResponse?.items],
|
||||
);
|
||||
const { root, children } = buildHierarchy(allOrganizations);
|
||||
|
||||
// When searching, match across ALL orgs (including children) by name in
|
||||
// either language and display the results as a flat list. When not
|
||||
// searching, fall back to the normal hierarchical view of root orgs.
|
||||
const trimmedSearch = searchTerm.trim().toLowerCase();
|
||||
const isSearching = trimmedSearch.length > 0;
|
||||
|
||||
const searchMatches = useMemo(() => {
|
||||
if (!isSearching) return [] as OrganizationDto[];
|
||||
return allOrganizations.filter(
|
||||
(org) =>
|
||||
(org.name?.en || "").toLowerCase().includes(trimmedSearch) ||
|
||||
(org.name?.am || "").toLowerCase().includes(trimmedSearch),
|
||||
);
|
||||
}, [allOrganizations, isSearching, trimmedSearch]);
|
||||
|
||||
// The base list to paginate over — full root in normal mode, the flat set
|
||||
// of matched orgs in search mode.
|
||||
const baseList = isSearching ? searchMatches : root;
|
||||
|
||||
// Paginate
|
||||
const startIndex = pageIndex * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedBase = baseList.slice(startIndex, endIndex);
|
||||
|
||||
// Build the final organizations list
|
||||
const organizations = useMemo(() => {
|
||||
const result: (OrganizationDto & {
|
||||
isChild?: boolean;
|
||||
parentName?: string;
|
||||
})[] = [];
|
||||
|
||||
if (isSearching) {
|
||||
// Flat search results — annotate with parent name if it's a child
|
||||
paginatedBase.forEach((org) => {
|
||||
const parent = org.parentId ? organizationMap.get(org.parentId) : null;
|
||||
result.push({
|
||||
...org,
|
||||
isChild: !!org.parentId,
|
||||
parentName: parent ? parent.name.en : undefined,
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
paginatedBase.forEach((parent) => {
|
||||
// Add parent organization
|
||||
result.push(parent);
|
||||
|
||||
// Add children if parent is expanded
|
||||
if (expandedParents.has(parent.id)) {
|
||||
const parentChildren = children.get(parent.id) || [];
|
||||
parentChildren.forEach((child) => {
|
||||
result.push({
|
||||
...child,
|
||||
isChild: true,
|
||||
parentName: parent.name.en,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [paginatedBase, children, expandedParents, isSearching, organizationMap]);
|
||||
|
||||
// Create custom column definition with expandable functionality
|
||||
const customColumns = useMemo(() => {
|
||||
return OrganizationsColumnDefn.map((column) => {
|
||||
if ("accessorKey" in column && column.accessorKey === "name.en") {
|
||||
return {
|
||||
...column,
|
||||
cell: ({ row }: any) => {
|
||||
const org = row.original;
|
||||
const isChild = org.isChild || false;
|
||||
const parentName = org.parentName;
|
||||
const hasChildren = children.has(org.id);
|
||||
const isExpanded = expandedParents.has(org.id);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{hasChildren && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 flex-shrink-0"
|
||||
onClick={() => toggleExpanded(org.id)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="font-medium flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
|
||||
{isChild && (
|
||||
<span className="text-gray-400 dark:text-gray-500 flex-shrink-0">└─</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate" title={org.name.en}>
|
||||
{localizedName(org.name)}
|
||||
</div>
|
||||
{parentName && (
|
||||
<div
|
||||
className="text-sm text-gray-500 dark:text-gray-400 font-normal truncate"
|
||||
title={`under ${parentName}`}
|
||||
>
|
||||
(under {parentName})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
}, [children, expandedParents, localizedName]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="col-span-2 shadow-none border-none bg-transparent px-0">
|
||||
<CardHeader className="flex flex-row justify-between items-center px-0">
|
||||
<CardTitle className="text-xl font-semibold ">
|
||||
{t("organization.organizations")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
<AdvancedTable
|
||||
columns={customColumns}
|
||||
data={organizations}
|
||||
tableName="Organizations"
|
||||
toolBarPosition="right"
|
||||
itemCount={baseList.length}
|
||||
pageSize={pageSize}
|
||||
onGlobalFilterChange={setSearchTerm}
|
||||
extraToolbar={
|
||||
<WithPermission
|
||||
perms={["create:organization", "activate:organization"]}
|
||||
>
|
||||
<Link to="/user-management/organizations/new">
|
||||
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("organization.newOrganization")}
|
||||
</Button>
|
||||
</Link>
|
||||
</WithPermission>
|
||||
}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={handlePageChange}
|
||||
nextFunction={() => handlePageChange(pageIndex + 1)}
|
||||
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
|
||||
refresh={refetch}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/common/ui/dropdown-menu";
|
||||
import { Button } from "../../../shared/common/ui/button";
|
||||
import {
|
||||
Archive,
|
||||
Delete,
|
||||
Edit,
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { OrganizationActivateOrDeactivationPopup } from "./common/OrganizationActivateOrDeactivationPopup";
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ManageOrganizationDrawer from "./ManageOrganizationDrawer";
|
||||
import { useArchiveActions } from "@/user-management/hooks/useArchived";
|
||||
interface OrganizationsActionsProps {
|
||||
rowData: OrganizationDto;
|
||||
}
|
||||
|
||||
const OrganizationsActions: React.FC<OrganizationsActionsProps> = ({
|
||||
rowData,
|
||||
}) => {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [mangeOrgConfigOpen, setManageOrgConfigOpen] = useState(false);
|
||||
|
||||
const {
|
||||
activateOrganization,
|
||||
deactivateOrganization,
|
||||
isOrganizationActivating,
|
||||
isOrganizationDeactivating,
|
||||
deleteOrganization,
|
||||
isOrganizationDeleting,
|
||||
} = useOrganizations("Org");
|
||||
const { softDeleteOrganization, isArchivingOrganization } =
|
||||
useArchiveActions();
|
||||
const handleEditDetails = () => {
|
||||
setDropdownOpen(false);
|
||||
navigate(`/user-management/organizations/edit/${rowData?.id}`);
|
||||
};
|
||||
const handleViewDetails = () => {
|
||||
setDropdownOpen(false);
|
||||
navigate(`/user-management/organizations/detail/${rowData?.id}`);
|
||||
};
|
||||
const handleManageOrganization = () => {
|
||||
setDropdownOpen(false);
|
||||
setManageOrgConfigOpen(true);
|
||||
};
|
||||
|
||||
const isActive = rowData.status === "Active";
|
||||
return (
|
||||
<div className="flex gap-3 align-center">
|
||||
<OrganizationActivateOrDeactivationPopup
|
||||
organizationName={rowData.name.en}
|
||||
isActive={isActive}
|
||||
onConfirm={() => {
|
||||
isActive
|
||||
? deactivateOrganization({
|
||||
id: rowData.id,
|
||||
})
|
||||
: activateOrganization({
|
||||
id: rowData.id,
|
||||
});
|
||||
}}
|
||||
isLoading={isOrganizationActivating || isOrganizationDeactivating}
|
||||
trigger={
|
||||
isActive ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={
|
||||
isActive
|
||||
? "text-red-600 dark:text-red-400 border-red-600 dark:border-red-500 hover:bg-red-50 dark:hover:bg-red-900/30"
|
||||
: "text-primary-600 dark:text-primary-400 border-primary-600 dark:border-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/30"
|
||||
}
|
||||
>
|
||||
{t("statusBar.deactivate")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={
|
||||
isActive
|
||||
? "text-red-600 dark:text-red-400 border-red-600 dark:border-red-500 hover:bg-red-50 dark:hover:bg-red-900/30"
|
||||
: "text-primary-600 dark:text-primary-400 border-primary-600 dark:border-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/30"
|
||||
}
|
||||
>
|
||||
{t("statusBar.activate")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={dropdownOpen}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-[200px]"
|
||||
onInteractOutside={(e) => {
|
||||
// Prevent closing when interacting with dialogs
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('[role="dialog"]')) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
|
||||
{/* Always show these */}
|
||||
<DropdownMenuItem onSelect={handleViewDetails}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{t("organization.viewOrganization")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleManageOrganization}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t("organization.manageOrganization", "Manage Organization")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleEditDetails}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{t("organization.editOrganization")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setDropdownOpen(false);
|
||||
softDeleteOrganization(rowData.id);
|
||||
}}
|
||||
disabled={isArchivingOrganization}
|
||||
className="text-amber-700 focus:text-amber-700"
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
{t("archive.archiveOrganization", "Archive Organization")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
{mangeOrgConfigOpen && (
|
||||
<ManageOrganizationDrawer
|
||||
manageOrgConfigOpen={mangeOrgConfigOpen}
|
||||
setManageOrgConfigOpen={setManageOrgConfigOpen}
|
||||
organizationId={rowData.id}
|
||||
isUnitConfig={false}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationsActions;
|
||||
@@ -0,0 +1,122 @@
|
||||
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import OrganizationsActions from "@/super-admin/components/organizations/OrganizationsActions";
|
||||
import { Badge } from "../../../shared/common/ui/badge";
|
||||
import { Users } from "lucide-react";
|
||||
import { StatusCell } from "../organizationAdmins/StatusCell";
|
||||
import { VisibilityCell } from "./VisibilityCell";
|
||||
import { format } from "date-fns";
|
||||
import { t } from "i18next";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
export const OrganizationsColumnDefn: ColumnDef<OrganizationDto>[] = [
|
||||
{
|
||||
accessorKey: "name.en",
|
||||
header: () => t("organization.organizationName"),
|
||||
size: 300,
|
||||
minSize: 200,
|
||||
maxSize: 400,
|
||||
cell: ({ row }) => {
|
||||
const org = row.original;
|
||||
const lang = i18n.language;
|
||||
// This will be overridden in the Organizations component with proper data
|
||||
return (
|
||||
<div className="font-medium">
|
||||
{org.parentId && <span className="text-gray-400 dark:text-gray-500 mr-2">└─</span>}
|
||||
{org.name.am}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: () => t("organization.createdOn"),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">
|
||||
{format(new Date(row.original.createdAt), "MMM dd, yyyy")}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "activeEmployeeCount",
|
||||
header: () => t("organization.numberOfUsers"),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium flex items-center">
|
||||
<Users className="h-4 w-4 mr-2 text-gray-500 dark:text-gray-400" />
|
||||
{row.original.activeEmployeeCount !== undefined
|
||||
? row.original.activeEmployeeCount
|
||||
: "-"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "adminsCount",
|
||||
header: () => t("organization.assignedAdmin"),
|
||||
cell: ({ row }) => (
|
||||
<StatusCell
|
||||
id={row.original.id}
|
||||
adminsCount={row.original.adminsCount || 0}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => t("userRecord.Status"),
|
||||
cell: ({ row }) => {
|
||||
const rowData = row.original;
|
||||
return (
|
||||
<div>
|
||||
<Badge
|
||||
className={`${
|
||||
rowData.status === "Active"
|
||||
? "bg-primary-100 text-primary-600 hover:bg-primary-100 dark:bg-primary-900/40 dark:text-primary-300 dark:hover:bg-primary-900/50"
|
||||
: "bg-red-100 text-red-600 hover:bg-red-100 dark:bg-red-900/40 dark:text-red-300 dark:hover:bg-red-900/50"
|
||||
} rounded-full px-6 py-1 font-medium`}
|
||||
>
|
||||
{rowData.status === "Active"
|
||||
? t("statusBar.activate")
|
||||
: t("statusBar.deactivate")}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "isGovernmentOrganization",
|
||||
header: () => t("organization.isGovernmentOrganization"),
|
||||
cell: ({ row }) => {
|
||||
const rowData = row.original;
|
||||
return (
|
||||
<div>
|
||||
<Badge
|
||||
className={`${
|
||||
rowData.isGovernmentOrganization
|
||||
? "bg-blue-600 text-white hover:bg-blue-400"
|
||||
: "bg-red-600 text-white hover:bg-red-400"
|
||||
} rounded-full px-6 py-1 font-medium`}
|
||||
>
|
||||
{rowData.isGovernmentOrganization === true
|
||||
? t("common.yes")
|
||||
: t("common.no")}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "visibility",
|
||||
header: () => t("organization.visibilityToOthers"),
|
||||
cell: ({ row }) => {
|
||||
const rowData = row.original;
|
||||
return <VisibilityCell organizationId={rowData.id} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Actions",
|
||||
header: () => t("userRecord.Actions"),
|
||||
cell: ({ row }) => {
|
||||
const rowData = row.original;
|
||||
return <OrganizationsActions rowData={rowData as OrganizationDto} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
createOrganizationConfig,
|
||||
updateOrganizationConfig,
|
||||
getOrganizationConfig,
|
||||
OrganizationConfig,
|
||||
OrganizationConfigListResponse,
|
||||
} from "@/shared/services/organizationConfigService";
|
||||
import { toast } from "sonner";
|
||||
import { Switch } from "@/shared/common/ui/switch";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface VisibilityCellProps {
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export const VisibilityCell = ({ organizationId }: VisibilityCellProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [config, setConfig] = useState<OrganizationConfig | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
// Load organization configuration
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
setIsInitialLoading(true);
|
||||
const response = await getOrganizationConfig(organizationId);
|
||||
|
||||
// Handle the new response format with count and items
|
||||
const responseData = response.data as OrganizationConfigListResponse;
|
||||
|
||||
if (
|
||||
responseData.count > 0 &&
|
||||
responseData.items &&
|
||||
responseData.items.length > 0
|
||||
) {
|
||||
// Take the first configuration item
|
||||
const configData = responseData.items[0];
|
||||
setConfig(configData);
|
||||
} else {
|
||||
// No config found - organization is visible by default
|
||||
setConfig(null);
|
||||
}
|
||||
} catch (error) {
|
||||
// If API call fails, assume no config exists (visible by default)
|
||||
console.warn(
|
||||
`Failed to load config for organization ${organizationId}:`,
|
||||
error
|
||||
);
|
||||
setConfig(null);
|
||||
} finally {
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadConfig();
|
||||
}, [organizationId]);
|
||||
|
||||
// Handle visibility toggle
|
||||
const handleVisibilityToggle = async (canReceive: boolean) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const payload = {
|
||||
canStartReceivingRecord: canReceive,
|
||||
organizationId: organizationId,
|
||||
};
|
||||
|
||||
// Simple logic: if we have both ID and organization ID, use PUT; if only organization ID, use POST
|
||||
if (config && config.id) {
|
||||
// We have both ID and organization ID - use PUT request
|
||||
await updateOrganizationConfig(config.id, payload);
|
||||
setConfig((prev: OrganizationConfig | null) =>
|
||||
prev ? { ...prev, canStartReceivingRecord: canReceive } : null
|
||||
);
|
||||
} else {
|
||||
// We only have organization ID - use POST request
|
||||
const response = await createOrganizationConfig(payload);
|
||||
setConfig(response.data);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
canReceive
|
||||
? t("organization.visibilityEnabled")
|
||||
: t("organization.visibilityDisabled")
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error updating organization visibility:", error);
|
||||
toast.error(t("organization.visibilityUpdateFailed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render until we have real data from API
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="w-4 h-4 border-2 border-gray-300 dark:border-gray-600 border-t-primary-600 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show real API result: if config exists, use its value; if no config, organization is visible by default
|
||||
const isVisible = config ? config.canStartReceivingRecord : true;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{isVisible ? t("organization.visible") : t("organization.hidden")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={isVisible}
|
||||
onCheckedChange={handleVisibilityToggle}
|
||||
disabled={isLoading}
|
||||
className="data-[state=checked]:bg-primary-600"
|
||||
/>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="w-4 h-4 border-2 border-gray-300 dark:border-gray-600 border-t-primary-600 rounded-full animate-spin"></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface OrganizationActivateOrDeactivationPopupProps {
|
||||
organizationName: string;
|
||||
isActive: boolean;
|
||||
onConfirm: () => void;
|
||||
isLoading: boolean;
|
||||
trigger: ReactNode;
|
||||
}
|
||||
|
||||
export const OrganizationActivateOrDeactivationPopup = ({
|
||||
organizationName,
|
||||
isActive,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
trigger,
|
||||
}: OrganizationActivateOrDeactivationPopupProps) => {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{isActive ? "Deactivate" : "Activate"} Organization
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to {isActive ? "deactivate" : "activate"}{" "}
|
||||
<span className="font-medium">{organizationName}</span>? This action
|
||||
can be reversed later.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading} className="cursor-pointer">
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
className={
|
||||
isActive ? "bg-red-600 hover:bg-red-700 cursor-pointer" : ""
|
||||
}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{isLoading
|
||||
? isActive
|
||||
? "Deactivating..."
|
||||
: "Activating..."
|
||||
: isActive
|
||||
? "Deactivate"
|
||||
: "Activate"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,324 @@
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { z } from "zod";
|
||||
import { useOrganizationTypes } from "@/super-admin/hooks/useOrganizationTypes";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect } from "react";
|
||||
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
import { Switch } from "@/shared/common/ui/switch";
|
||||
import { t } from "i18next";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
|
||||
const orgSchema = z.object({
|
||||
name: z.object({
|
||||
am: z.string().min(1, "Amharic name is required"),
|
||||
en: z.string().min(1, "English name is required"),
|
||||
}),
|
||||
key: z.string().min(1, "Key is required"),
|
||||
organizationTypeId: z.string().min(1, "Organization Type is required"),
|
||||
parentId: z.string().optional(),
|
||||
isGovernmentOrganization: z.boolean(),
|
||||
});
|
||||
|
||||
export type FormValues = z.infer<typeof orgSchema>;
|
||||
|
||||
interface OrganizationFormProps {
|
||||
onSubmit: (values: FormValues) => void;
|
||||
type: "Create" | "Edit";
|
||||
isLoading: boolean;
|
||||
organizationDetails?: OrganizationDto;
|
||||
}
|
||||
|
||||
const OrganizationForm: React.FC<OrganizationFormProps> = ({
|
||||
onSubmit,
|
||||
isLoading,
|
||||
type,
|
||||
organizationDetails,
|
||||
}) => {
|
||||
const fieldClassName =
|
||||
"h-10 rounded-md border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary";
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(orgSchema),
|
||||
defaultValues: {
|
||||
name: { am: "", en: "" },
|
||||
key: "",
|
||||
organizationTypeId: "",
|
||||
parentId: undefined,
|
||||
isGovernmentOrganization: true,
|
||||
},
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
const localizedName = useLocalizedName();
|
||||
const {
|
||||
organizationTypesResponse,
|
||||
isLoading: isLoadingOrgTypes,
|
||||
isError: orgTypesError,
|
||||
} = useOrganizationTypes();
|
||||
|
||||
const {
|
||||
organizationsResponse,
|
||||
isLoading: isLoadingOrgs,
|
||||
isError: orgsError,
|
||||
} = useOrganizations("Org", {
|
||||
take: 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const en = form.watch("name.en");
|
||||
|
||||
if (en) {
|
||||
const commonNumber = "001"; // Example number
|
||||
const generatedKey = `${en || ""}_${commonNumber}`
|
||||
.replace(/\s+/g, "_") // Replace spaces with underscores
|
||||
.toUpperCase();
|
||||
|
||||
form.setValue("key", generatedKey);
|
||||
}
|
||||
}, [form.watch("name.en")]);
|
||||
|
||||
useEffect(() => {
|
||||
if (organizationDetails) {
|
||||
form.reset({
|
||||
name: organizationDetails.name,
|
||||
key: organizationDetails.key,
|
||||
organizationTypeId: organizationDetails.organizationTypeId,
|
||||
parentId: organizationDetails.parentId ?? undefined,
|
||||
isGovernmentOrganization:
|
||||
organizationDetails.isGovernmentOrganization ?? true,
|
||||
});
|
||||
}
|
||||
}, [organizationDetails]);
|
||||
|
||||
return (
|
||||
<div className="px-2 md:px-4">
|
||||
<Card className="max-w-5xl mx-auto shadow-md border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<CardHeader className="border-b border-gray-200 dark:border-gray-700 bg-gray-50/70 dark:bg-gray-900/40">
|
||||
<CardTitle className="text-xl md:text-2xl font-semibold text-gray-800 dark:text-gray-100">
|
||||
{type === "Create"
|
||||
? t("organization.createNew")
|
||||
: t("organization.edit")}{" "}
|
||||
{t("organization.organizations")}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-500 dark:text-gray-400">
|
||||
{t("organization.enterDetails")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 md:p-8">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900/30 p-4 md:p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name.am">
|
||||
{t("organization.nameAmharic")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input {...form.register("name.am")} className={fieldClassName} />
|
||||
{form.formState.errors.name?.am && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
{form.formState.errors.name.am.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name.en">
|
||||
{t("organization.nameEnglish")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input {...form.register("name.en")} className={fieldClassName} />
|
||||
{form.formState.errors.name?.en && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
{form.formState.errors.name.en.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">
|
||||
{t("organization.key")}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
{...form.register("key")}
|
||||
disabled={type === "Edit"}
|
||||
readOnly={type === "Edit"}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
{form.formState.errors.key && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
{form.formState.errors.key.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="organizationType">
|
||||
{t("organization.organizationType")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={form.watch("organizationTypeId")}
|
||||
onValueChange={(val: string) =>
|
||||
form.setValue("organizationTypeId", val)
|
||||
}
|
||||
disabled={isLoadingOrgTypes}
|
||||
>
|
||||
<SelectTrigger className={fieldClassName}>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingOrgTypes
|
||||
? t("organization.loading")
|
||||
: t("organization.selectOrganizationType")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isLoadingOrgTypes ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
{t("organization.loadingOrganizationTypes")}
|
||||
</SelectItem>
|
||||
) : orgTypesError ? (
|
||||
<SelectItem value="error" disabled>
|
||||
{t("organization.errorLoadingOrganizationTypes")}
|
||||
</SelectItem>
|
||||
) : organizationTypesResponse?.items?.length ? (
|
||||
organizationTypesResponse.items.map((type) => (
|
||||
<SelectItem key={type.id} value={type.id}>
|
||||
{localizedName(type.name)}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="none" disabled>
|
||||
{t("organization.noOrganizationTypesAvailable")}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.organizationTypeId && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
{form.formState.errors.organizationTypeId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="organizationType">
|
||||
{t("organization.parentOrganization")}
|
||||
</Label>
|
||||
<Select
|
||||
value={form.watch("parentId")}
|
||||
onValueChange={(val: string) => form.setValue("parentId", val)}
|
||||
disabled={isLoadingOrgs}
|
||||
>
|
||||
<SelectTrigger className={fieldClassName}>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingOrgs
|
||||
? t("organization.loading")
|
||||
: t("organization.selectOrganization")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isLoadingOrgs ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
Loading organization types...
|
||||
</SelectItem>
|
||||
) : orgsError ? (
|
||||
<SelectItem value="error" disabled>
|
||||
Error loading organization types
|
||||
</SelectItem>
|
||||
) : organizationsResponse?.items?.length ? (
|
||||
organizationsResponse.items.map((type) => (
|
||||
<SelectItem key={type.id} value={type.id}>
|
||||
{localizedName(type.name)}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="none" disabled>
|
||||
{t("organization.noOrganization")}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.organizationTypeId && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
{form.formState.errors.organizationTypeId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 md:col-span-2 xl:col-span-1">
|
||||
<Label htmlFor="isPublic">
|
||||
{t("organization.isOrganizationPublic")}
|
||||
</Label>
|
||||
<div className="h-10 px-3 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 flex items-center justify-end">
|
||||
<Switch
|
||||
id="isPublic"
|
||||
checked={form.watch("isGovernmentOrganization")}
|
||||
onCheckedChange={(value) =>
|
||||
form.setValue("isGovernmentOrganization", value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{form.formState.errors.isGovernmentOrganization && (
|
||||
<p className="text-red-500 text-xs mt-1">
|
||||
{form.formState.errors.isGovernmentOrganization.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigate(-1);
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{t("common.Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
>
|
||||
{isLoading
|
||||
? `${type === "Create" ? "Creating" : "Editing"}...`
|
||||
: `${
|
||||
type === "Create" ? t("common.create") : t("common.Edit")
|
||||
} `}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationForm;
|
||||
@@ -0,0 +1,441 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2, Lock } from "lucide-react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/shared/common/ui/form";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Switch } from "@/shared/common/ui/switch";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
usePasswordSettingsQuery,
|
||||
usePasswordSettingsMutations,
|
||||
} from "../../hooks/usePasswordSettings";
|
||||
import { PasswordSettings } from "../../services/api/passwordSettingsService";
|
||||
import Loader from "@/record-management/components/Loader/loader";
|
||||
|
||||
interface PasswordSettingsFormValues {
|
||||
minimumPasswordLength: number;
|
||||
maximumPasswordLength: number;
|
||||
passwordExpiry: number;
|
||||
sessionTimeout: number;
|
||||
isDefaultPasswordEnabled: boolean;
|
||||
defaultPassword: string;
|
||||
}
|
||||
|
||||
interface PasswordSettingsFormProps {
|
||||
onSuccessCallback?: () => void;
|
||||
isDialogForm?: boolean;
|
||||
}
|
||||
|
||||
export default function PasswordSettingsForm({
|
||||
onSuccessCallback,
|
||||
isDialogForm = false,
|
||||
}: PasswordSettingsFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { data: settings, isLoading, error } = usePasswordSettingsQuery();
|
||||
const mutations = usePasswordSettingsMutations();
|
||||
|
||||
const form = useForm<PasswordSettingsFormValues>({
|
||||
defaultValues: {
|
||||
minimumPasswordLength: 8,
|
||||
maximumPasswordLength: 64,
|
||||
passwordExpiry: 90,
|
||||
sessionTimeout: 30,
|
||||
isDefaultPasswordEnabled: false,
|
||||
defaultPassword: "Welcome@123",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
form.reset({
|
||||
minimumPasswordLength: settings.minimumPasswordLength || 8,
|
||||
maximumPasswordLength: settings.maximumPasswordLength || 64,
|
||||
passwordExpiry: settings.passwordExpiry || 90,
|
||||
sessionTimeout: settings.sessionTimeout || 30,
|
||||
isDefaultPasswordEnabled: settings.isDefaultPasswordEnabled || false,
|
||||
defaultPassword: settings.defaultPassword || "Welcome@123",
|
||||
});
|
||||
}
|
||||
}, [settings, form]);
|
||||
|
||||
const onSubmit = async (values: PasswordSettingsFormValues) => {
|
||||
if (values.minimumPasswordLength > values.maximumPasswordLength) {
|
||||
toast.error(
|
||||
t(
|
||||
"passwordSettings.minMaxError",
|
||||
"Minimum password length cannot be greater than maximum"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
values.isDefaultPasswordEnabled &&
|
||||
!values.defaultPassword?.trim()
|
||||
) {
|
||||
toast.error(
|
||||
t(
|
||||
"passwordSettings.defaultPasswordRequired",
|
||||
"Default password is required when enabled"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const payload: PasswordSettings = {
|
||||
...values,
|
||||
id: settings?.id,
|
||||
};
|
||||
|
||||
if (settings?.id) {
|
||||
await mutations.update.mutateAsync(payload);
|
||||
} else {
|
||||
await mutations.create.mutateAsync(payload);
|
||||
}
|
||||
|
||||
// Call success callback if provided (for dialog form)
|
||||
if (onSuccessCallback) {
|
||||
onSuccessCallback();
|
||||
}
|
||||
|
||||
// Reset form after successful submission
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
// Error is handled by the hook
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="border-red-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-600">
|
||||
{t("passwordSettings.error", "Error Loading Settings")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600">
|
||||
{t(
|
||||
"passwordSettings.errorMessage",
|
||||
"Failed to load password settings. Please try again."
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={isDialogForm ? "w-full border-0 shadow-none" : "w-full"}>
|
||||
{!isDialogForm && (
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("passwordSettings.title", "Password Settings")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"passwordSettings.description",
|
||||
"Configure password policies and security settings for your organization"
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
)}
|
||||
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Password Length Section */}
|
||||
<div className="space-y-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-900 dark:border-gray-800">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
{t("passwordSettings.passwordLength", "Password Length Requirements")}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="minimumPasswordLength"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"passwordSettings.minimumLength",
|
||||
"Minimum Password Length"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value))
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
{t("passwordSettings.minimumLengthDesc", "Minimum: 1, Recommended: 8")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maximumPasswordLength"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"passwordSettings.maximumLength",
|
||||
"Maximum Password Length"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="256"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value))
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
{t("passwordSettings.maximumLengthDesc", "Maximum: 256, Recommended: 64")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Expiry Section */}
|
||||
<div className="space-y-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-900 dark:border-gray-800">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
{t("passwordSettings.passwordExpiry", "Password Expiry Policy")}
|
||||
</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="passwordExpiry"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"passwordSettings.passwordExpiryDays",
|
||||
"Password Expiry (Days)"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="365"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value))
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"passwordSettings.passwordExpiryDesc",
|
||||
"Number of days before passwords expire (0 = never expires)"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Session Timeout Section */}
|
||||
<div className="space-y-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-900 dark:border-gray-800">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
{t("passwordSettings.sessionManagement", "Session Management")}
|
||||
</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sessionTimeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"passwordSettings.sessionTimeout",
|
||||
"Session Timeout (Minutes)"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value))
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"passwordSettings.sessionTimeoutDesc",
|
||||
"Idle session timeout in minutes (1 minute to 24 hours)"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default Password Section */}
|
||||
<div className="space-y-4 p-4 bg-gray-50 rounded-lg border border-gray-200 dark:bg-gray-900 dark:border-gray-800">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
{t("passwordSettings.defaultPassword", "Default Password")}
|
||||
</h3>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isDefaultPasswordEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border border-gray-200 p-3 dark:border-gray-800">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"passwordSettings.enableDefaultPassword",
|
||||
"Enable Default Password"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"passwordSettings.enableDefaultPasswordDesc",
|
||||
"Use a default password for new users"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("isDefaultPasswordEnabled") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="defaultPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"passwordSettings.defaultPasswordValue",
|
||||
"Default Password"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter default password"
|
||||
{...field}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
{t(
|
||||
"passwordSettings.defaultPasswordValueDesc",
|
||||
"This password will be used for newly created user accounts"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => form.reset()}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.reset", "Reset")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="bg-primary hover:bg-primary-700"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{t("common.saving", "Saving")}
|
||||
</>
|
||||
) : (
|
||||
t("common.save", "Save Changes")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { SiteDto, SitePayloadDto } from "@/super-admin/dto/SitesDto";
|
||||
import { toSnakeCase } from "@/super-admin/hooks/useSites";
|
||||
|
||||
interface SiteFormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: SitePayloadDto) => void;
|
||||
site?: SiteDto | null; // If provided, we are in Edit mode
|
||||
isSubmitting: boolean;
|
||||
existingKeys: string[]; // for frontend uniqueness check
|
||||
}
|
||||
|
||||
export default function SiteFormModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
site = null,
|
||||
isSubmitting,
|
||||
existingKeys,
|
||||
}: SiteFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = !!site;
|
||||
|
||||
const [nameAm, setNameAm] = useState("");
|
||||
const [nameEn, setNameEn] = useState("");
|
||||
const [key, setKey] = useState("");
|
||||
const [domain, setDomain] = useState("");
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (site) {
|
||||
setNameAm(site.name?.am || "");
|
||||
setNameEn(site.name?.en || "");
|
||||
setKey(site.key || "");
|
||||
setDomain(site.domain || "");
|
||||
} else {
|
||||
setNameAm("");
|
||||
setNameEn("");
|
||||
setKey("");
|
||||
setDomain("");
|
||||
}
|
||||
setErrors({});
|
||||
}
|
||||
}, [isOpen, site]);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!nameAm.trim()) {
|
||||
newErrors.nameAm = t("sites.form.nameAmRequired");
|
||||
}
|
||||
if (!nameEn.trim()) {
|
||||
newErrors.nameEn = t("sites.form.nameEnRequired");
|
||||
}
|
||||
|
||||
if (!key.trim()) {
|
||||
newErrors.key = t("sites.form.keyRequired");
|
||||
} else if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
|
||||
newErrors.key = t("sites.form.keyFormat");
|
||||
} else if (!isEdit && existingKeys.includes(key.trim())) {
|
||||
newErrors.key = t("sites.form.keyUnique");
|
||||
}
|
||||
|
||||
if (!domain.trim()) {
|
||||
newErrors.domain = t("sites.form.domainRequired");
|
||||
} else if (/\s/.test(domain)) {
|
||||
newErrors.domain = t("sites.form.domainNoSpaces");
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
onSubmit({
|
||||
name: {
|
||||
am: nameAm.trim(),
|
||||
en: nameEn.trim(), // transformed to snake_case in the hook/mutation
|
||||
},
|
||||
key: key.trim(),
|
||||
domain: domain.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold text-slate-800 dark:text-slate-100">
|
||||
{isEdit ? t("sites.form.titleEdit") : t("sites.form.titleAdd")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5 py-2">
|
||||
{/* Site Name (Amharic) */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="nameAm" className="text-sm font-semibold">
|
||||
{t("sites.form.nameAmLabel")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="nameAm"
|
||||
value={nameAm}
|
||||
onChange={(e) => setNameAm(e.target.value)}
|
||||
placeholder={t("sites.form.nameAmPlaceholder")}
|
||||
className={errors.nameAm ? "border-red-500 focus-visible:ring-red-500" : ""}
|
||||
/>
|
||||
{errors.nameAm && (
|
||||
<span className="text-xs text-red-500 font-medium">{errors.nameAm}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Site Name (English) */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="nameEn" className="text-sm font-semibold">
|
||||
{t("sites.form.nameEnLabel")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="nameEn"
|
||||
value={nameEn}
|
||||
onChange={(e) => setNameEn(e.target.value)}
|
||||
placeholder={t("sites.form.nameEnPlaceholder")}
|
||||
className={errors.nameEn ? "border-red-500 focus-visible:ring-red-500" : ""}
|
||||
/>
|
||||
{nameEn.trim() && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
{t("sites.form.previewLabel")} <span className="font-mono text-primary font-semibold">{toSnakeCase(nameEn)}</span>
|
||||
</p>
|
||||
)}
|
||||
{errors.nameEn && (
|
||||
<span className="text-xs text-red-500 font-medium">{errors.nameEn}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Site Key */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="key" className="text-sm font-semibold">
|
||||
{t("sites.form.keyLabel")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="key"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder={t("sites.form.keyPlaceholder")}
|
||||
disabled={isEdit}
|
||||
className={errors.key ? "border-red-500 focus-visible:ring-red-500" : ""}
|
||||
/>
|
||||
{!isEdit && (
|
||||
<p className="text-xs text-slate-400">
|
||||
{t("sites.form.keyHint")}
|
||||
</p>
|
||||
)}
|
||||
{errors.key && (
|
||||
<span className="text-xs text-red-500 font-medium">{errors.key}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Site Domain */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="domain" className="text-sm font-semibold">
|
||||
{t("sites.form.domainLabel")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="domain"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
placeholder={t("sites.form.domainPlaceholder")}
|
||||
className={errors.domain ? "border-red-500 focus-visible:ring-red-500" : ""}
|
||||
/>
|
||||
{errors.domain && (
|
||||
<span className="text-xs text-red-500 font-medium">{errors.domain}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-4 border-t gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSubmitting ? t("sites.form.saving") : isEdit ? t("sites.form.saveChanges") : t("sites.form.createSite")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SiteDto } from "@/super-admin/dto/SitesDto";
|
||||
|
||||
interface SiteViewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
site?: SiteDto | null;
|
||||
}
|
||||
|
||||
export default function SiteViewModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
site,
|
||||
}: SiteViewModalProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!site) return null;
|
||||
|
||||
const isActive = site.status === "Active" || site.status !== "Archived";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-bold text-slate-800 dark:text-slate-100">
|
||||
{t("sites.view.title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-2 space-y-4">
|
||||
{/* Detail cards */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 p-3 bg-slate-50 dark:bg-slate-900 rounded-lg space-y-1">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
{t("sites.view.nameEn")}
|
||||
</span>
|
||||
<p className="text-base font-semibold text-slate-800 dark:text-slate-100">
|
||||
{site.name?.en || "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 p-3 bg-slate-50 dark:bg-slate-900 rounded-lg space-y-1">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
{t("sites.view.nameAm")}
|
||||
</span>
|
||||
<p className="text-base font-semibold text-slate-800 dark:text-slate-100">
|
||||
{site.name?.am || "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-50 dark:bg-slate-900 rounded-lg space-y-1">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
{t("sites.view.siteKey")}
|
||||
</span>
|
||||
<p className="text-base font-mono text-slate-800 dark:text-slate-100 truncate" title={site.key}>
|
||||
{site.key || "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-50 dark:bg-slate-900 rounded-lg space-y-1">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
{t("sites.view.status")}
|
||||
</span>
|
||||
<div>
|
||||
<Badge
|
||||
className={`${
|
||||
isActive
|
||||
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-950/40"
|
||||
: "bg-amber-100 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-950/40"
|
||||
} rounded-full px-3 py-0.5 font-medium border-none`}
|
||||
>
|
||||
{site.status || "Active"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 p-3 bg-slate-50 dark:bg-slate-900 rounded-lg space-y-1">
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
{t("sites.view.domain")}
|
||||
</span>
|
||||
<p className="text-base font-semibold text-slate-800 dark:text-slate-100 truncate" title={site.domain}>
|
||||
{site.domain || "-"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-4">
|
||||
<Button onClick={onClose} className="w-full sm:w-auto">
|
||||
{t("sites.view.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { SiteDto } from "@/super-admin/dto/SitesDto";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/common/ui/dropdown-menu";
|
||||
import { Archive, Trash2, Edit, Eye, MoreHorizontal, RefreshCw, Palette } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
|
||||
interface ColumnsProps {
|
||||
onView: (site: SiteDto) => void;
|
||||
onEdit: (site: SiteDto) => void;
|
||||
onArchive: (site: SiteDto) => void;
|
||||
onRestore: (site: SiteDto) => void;
|
||||
onDelete: (site: SiteDto) => void;
|
||||
onBranding: (site: SiteDto) => void;
|
||||
}
|
||||
|
||||
export const getSitesColumnDefn = ({
|
||||
onView,
|
||||
onEdit,
|
||||
onArchive,
|
||||
onRestore,
|
||||
onDelete,
|
||||
onBranding,
|
||||
}: ColumnsProps): ColumnDef<SiteDto>[] => [
|
||||
{
|
||||
id: "name",
|
||||
header: () => t("sites.columns.siteName"),
|
||||
cell: ({ row }) => {
|
||||
const site = row.original;
|
||||
return (
|
||||
<div>
|
||||
<div className="font-semibold text-slate-800 dark:text-slate-200">
|
||||
{site.name?.en || "-"}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{site.name?.am || "-"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "key",
|
||||
header: () => t("sites.columns.key"),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-slate-700 dark:text-slate-300">
|
||||
{row.original.key}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "domain",
|
||||
header: () => t("sites.columns.domain"),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-slate-700 dark:text-slate-300">
|
||||
{row.original.domain}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: () => t("sites.columns.status"),
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status || "Active";
|
||||
const isActive = status === "Active";
|
||||
return (
|
||||
<Badge
|
||||
className={`${
|
||||
isActive
|
||||
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-950/40"
|
||||
: "bg-amber-100 text-amber-800 dark:bg-amber-950/40 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-950/40"
|
||||
} rounded-full px-3 py-0.5 font-medium border-none`}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => t("sites.columns.actions"),
|
||||
cell: ({ row }) => {
|
||||
const site = row.original;
|
||||
const isArchived = site.status === "Archived";
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">Open Menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[160px]">
|
||||
<DropdownMenuLabel>{t("sites.columns.actions")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => onView(site)}>
|
||||
<Eye className="mr-2 h-4 w-4 text-slate-500" />
|
||||
{t("sites.actions.view")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEdit(site)}>
|
||||
<Edit className="mr-2 h-4 w-4 text-blue-500" />
|
||||
{t("sites.actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onBranding(site)}>
|
||||
<Palette className="mr-2 h-4 w-4 text-purple-500" />
|
||||
{t("sites.actions.branding")}
|
||||
</DropdownMenuItem>
|
||||
{!isArchived ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onArchive(site)}
|
||||
className="text-amber-600 focus:text-amber-600 cursor-pointer"
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4 text-amber-500" />
|
||||
{t("sites.actions.archive")}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onRestore(site)}
|
||||
className="text-emerald-600 focus:text-emerald-600 cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4 text-emerald-500" />
|
||||
{t("sites.actions.restore")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(site)}
|
||||
className="text-rose-600 focus:text-rose-600 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4 text-rose-500" />
|
||||
{t("sites.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useSites } from "@/super-admin/hooks/useSites";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||
import { getSitesColumnDefn } from "./SitesColumnDefn";
|
||||
import { Loader, Plus } from "lucide-react";
|
||||
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 { SiteDto, SitePayloadDto } from "@/super-admin/dto/SitesDto";
|
||||
import SiteFormModal from "./SiteFormModal";
|
||||
import SiteViewModal from "./SiteViewModal";
|
||||
|
||||
export default function SitesPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// Modals state
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
const [selectedSite, setSelectedSite] = useState<SiteDto | null>(null);
|
||||
|
||||
// Dialogs state for archive/delete/restore
|
||||
const [archiveDialogOpen, setArchiveDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
|
||||
|
||||
// Query & mutation hook
|
||||
const {
|
||||
sitesResponse,
|
||||
isLoading,
|
||||
refetch,
|
||||
createSite,
|
||||
isCreating,
|
||||
updateSite,
|
||||
isUpdating,
|
||||
archiveSite,
|
||||
isArchiving,
|
||||
deleteSite,
|
||||
isDeleting,
|
||||
restoreSite,
|
||||
isRestoring,
|
||||
} = useSites({
|
||||
take: pageSize,
|
||||
skip: pageIndex * pageSize,
|
||||
orderBy: "createdAt:DESC",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPageIndex(0);
|
||||
}, [searchTerm, pageSize]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPageIndex(newPage);
|
||||
};
|
||||
|
||||
const handleAddNewClick = () => {
|
||||
setSelectedSite(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleFormSubmit = (payload: SitePayloadDto) => {
|
||||
if (selectedSite) {
|
||||
updateSite(
|
||||
{ id: selectedSite.id, payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setFormOpen(false);
|
||||
setSelectedSite(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
createSite(payload, {
|
||||
onSuccess: () => {
|
||||
setFormOpen(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveConfirm = () => {
|
||||
if (selectedSite) {
|
||||
archiveSite(
|
||||
{ id: selectedSite.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setArchiveDialogOpen(false);
|
||||
setSelectedSite(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (selectedSite) {
|
||||
deleteSite(
|
||||
{ id: selectedSite.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedSite(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreConfirm = () => {
|
||||
if (selectedSite) {
|
||||
restoreSite(
|
||||
{ id: selectedSite.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRestoreDialogOpen(false);
|
||||
setSelectedSite(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Extract all sites from response
|
||||
const allSites = useMemo(() => sitesResponse?.items || [], [sitesResponse]);
|
||||
// Existing keys list (for create uniqueness validation)
|
||||
const existingKeys = useMemo(() => allSites.map((s) => s.key), [allSites]);
|
||||
|
||||
// Get table column definitions
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getSitesColumnDefn({
|
||||
onView: (site) => {
|
||||
setSelectedSite(site);
|
||||
setViewOpen(true);
|
||||
},
|
||||
onEdit: (site) => {
|
||||
setSelectedSite(site);
|
||||
setFormOpen(true);
|
||||
},
|
||||
onArchive: (site) => {
|
||||
setSelectedSite(site);
|
||||
setArchiveDialogOpen(true);
|
||||
},
|
||||
onRestore: (site) => {
|
||||
setSelectedSite(site);
|
||||
setRestoreDialogOpen(true);
|
||||
},
|
||||
onDelete: (site) => {
|
||||
setSelectedSite(site);
|
||||
setDeleteDialogOpen(true);
|
||||
},
|
||||
onBranding: (site) => {
|
||||
navigate(`/user-management/web-management/Branding/Branding/${site.id}`);
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="col-span-2 shadow-none border-none bg-transparent px-0">
|
||||
<CardHeader className="flex flex-row justify-between items-center px-0">
|
||||
<CardTitle className="text-2xl font-bold text-slate-800 dark:text-slate-100">
|
||||
{t("sites.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={sitesResponse?.items || []}
|
||||
tableName={t("sites.tableName")}
|
||||
toolBarPosition="right"
|
||||
itemCount={sitesResponse?.count || 0}
|
||||
pageSize={pageSize}
|
||||
onGlobalFilterChange={setSearchTerm}
|
||||
extraToolbar={
|
||||
<Button
|
||||
onClick={handleAddNewClick}
|
||||
className="px-5 py-2 rounded-md text-sm font-medium shadow-md cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("sites.addNew")}
|
||||
</Button>
|
||||
}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={setPageSize}
|
||||
nextFunction={() => handlePageChange(pageIndex + 1)}
|
||||
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
|
||||
refresh={refetch}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create / Edit Form Modal */}
|
||||
<SiteFormModal
|
||||
isOpen={formOpen}
|
||||
onClose={() => {
|
||||
setFormOpen(false);
|
||||
setSelectedSite(null);
|
||||
}}
|
||||
onSubmit={handleFormSubmit}
|
||||
site={selectedSite}
|
||||
isSubmitting={isCreating || isUpdating}
|
||||
existingKeys={existingKeys}
|
||||
/>
|
||||
|
||||
{/* Read-Only View Modal */}
|
||||
<SiteViewModal
|
||||
isOpen={viewOpen}
|
||||
onClose={() => {
|
||||
setViewOpen(false);
|
||||
setSelectedSite(null);
|
||||
}}
|
||||
site={selectedSite}
|
||||
/>
|
||||
|
||||
{/* Archive Confirmation (Soft Delete) */}
|
||||
<AlertDialog
|
||||
open={archiveDialogOpen}
|
||||
onOpenChange={(open) => !open && setArchiveDialogOpen(false)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-lg font-bold">
|
||||
{t("sites.dialogs.archiveTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("sites.dialogs.archiveDescription", {
|
||||
name: selectedSite?.name?.en,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
disabled={isArchiving}
|
||||
onClick={() => {
|
||||
setArchiveDialogOpen(false);
|
||||
setSelectedSite(null);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isArchiving}
|
||||
onClick={handleArchiveConfirm}
|
||||
className="bg-amber-600 hover:bg-amber-700 text-white cursor-pointer"
|
||||
>
|
||||
{isArchiving
|
||||
? t("sites.dialogs.archiving")
|
||||
: t("sites.dialogs.archiveConfirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Permanent Delete Confirmation (Hard Delete) */}
|
||||
<AlertDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={(open) => !open && setDeleteDialogOpen(false)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-lg font-bold text-red-600">
|
||||
{t("sites.dialogs.deleteTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t("sites.dialogs.deleteDescription", {
|
||||
name: selectedSite?.name?.en,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
disabled={isDeleting}
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedSite(null);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isDeleting}
|
||||
onClick={handleDeleteConfirm}
|
||||
className="bg-red-600 hover:bg-red-700 text-white cursor-pointer"
|
||||
>
|
||||
{isDeleting
|
||||
? t("sites.dialogs.deleting")
|
||||
: t("sites.dialogs.deleteConfirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Restore Confirmation */}
|
||||
<AlertDialog
|
||||
open={restoreDialogOpen}
|
||||
onOpenChange={(open) => !open && setRestoreDialogOpen(false)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-lg font-bold">
|
||||
{t("sites.dialogs.restoreTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("sites.dialogs.restoreDescription", {
|
||||
name: selectedSite?.name?.en,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
disabled={isRestoring}
|
||||
onClick={() => {
|
||||
setRestoreDialogOpen(false);
|
||||
setSelectedSite(null);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isRestoring}
|
||||
onClick={handleRestoreConfirm}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
|
||||
>
|
||||
{isRestoring
|
||||
? t("sites.dialogs.restoring")
|
||||
: t("sites.dialogs.restoreConfirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { FileText, Plus } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { useState } from "react";
|
||||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||
import { useTemplate } from "../service/useTemplate";
|
||||
import { TemplateForm } from "./TemplateForm";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { t } from "i18next";
|
||||
import i18n from "@/i18n";
|
||||
import { CreateTemplateTypes } from "../types/templateTypes";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
const TemplateCard = () => {
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const {
|
||||
templates,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
isCreatingTemplate,
|
||||
isUpdatingTemplate,
|
||||
} = useTemplate();
|
||||
|
||||
const lang = i18n.language;
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
const templateList: CreateTemplateTypes[] = Array.isArray(templates) ? templates : templates?.items || [];
|
||||
const selectedTemplate =
|
||||
templateList.find((t) => t.id === selectedTemplateId) ?? null;
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
{t("contentManagement.letterTemplate")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("contentManagement.createMsg")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateForm(true)} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("contentManagement.addTemplate")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</>
|
||||
) : isError ? (
|
||||
<p className="text-sm text-red-600">
|
||||
{t("contentManagement.failedToLoadTemplates")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{templateList.length > 0 && (
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setSelectedTemplateId(value);
|
||||
setShowCreateForm(false);
|
||||
}}
|
||||
value={selectedTemplateId ?? undefined}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("contentManagement.selectTemplate")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templateList.map((template) => (
|
||||
<SelectItem key={template.id!} value={template.id!}>
|
||||
{lang === "en" ? template.name.en : template.name.am}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{(selectedTemplate || showCreateForm) && (
|
||||
<TemplateForm
|
||||
isSubmitting={isCreatingTemplate || isUpdatingTemplate}
|
||||
template={selectedTemplate}
|
||||
onCancel={() => {
|
||||
setShowCreateForm(false);
|
||||
setSelectedTemplateId(null);
|
||||
}}
|
||||
onSubmitCreate={(values) => {
|
||||
if (selectedTemplate?.id) {
|
||||
updateTemplate(
|
||||
{ id: selectedTemplate.id, template: values },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("contentManagement.updateTemplate"));
|
||||
refetch();
|
||||
setShowCreateForm(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
} else {
|
||||
createTemplate(values, {
|
||||
onSuccess: (newTemplate: any) => {
|
||||
toast.success(
|
||||
`${values.name.en} ${t("contentManagement.templateSuccessMsg")}`
|
||||
);
|
||||
refetch();
|
||||
setShowCreateForm(false);
|
||||
// setSelectedTemplateId(newTemplate.id);
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateCard;
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { useMemo, useState, useEffect } from "react";
|
||||
import { Editor as TinyMCEEditor } from "@/record-management/common/editor/rte";
|
||||
import type { Editor as TinyMCEEditorType } from "tinymce";
|
||||
|
||||
export interface Placeholder {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TemplateEditorProps {
|
||||
value: string;
|
||||
onEditorChange: (value: string, editor: TinyMCEEditorType) => void;
|
||||
apiKey?: string;
|
||||
placeholders?: Placeholder[];
|
||||
}
|
||||
|
||||
export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
||||
value,
|
||||
onEditorChange,
|
||||
apiKey,
|
||||
placeholders,
|
||||
}) => {
|
||||
const hasPlaceholders = placeholders && placeholders.length > 0;
|
||||
const [isDarkMode, setIsDarkMode] = useState(() =>
|
||||
typeof window !== "undefined" && document.documentElement.classList.contains("dark")
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const init = useMemo(() => {
|
||||
return {
|
||||
height: 600,
|
||||
menubar: true,
|
||||
skin: isDarkMode ? "oxide-dark" : "oxide",
|
||||
skin_url: isDarkMode ? "/tinymce/skins/ui/oxide-dark" : "/tinymce/skins/ui/oxide",
|
||||
content_css: isDarkMode ? "dark" : "default",
|
||||
content_style: `
|
||||
body {
|
||||
font-family:Helvetica,Arial,sans-serif;
|
||||
font-size:14px;
|
||||
${isDarkMode ? `
|
||||
background-color: #1f2937;
|
||||
color: #e5e7eb;
|
||||
` : `
|
||||
background-color: #ffffff;
|
||||
color: #1f2937;
|
||||
`}
|
||||
}
|
||||
${hasPlaceholders ? `
|
||||
.placeholder {
|
||||
${isDarkMode ? `
|
||||
background-color: #1e3a5f;
|
||||
border: 1px dashed #60a5fa;
|
||||
color: #93c5fd;
|
||||
` : `
|
||||
background-color: #e0f2fe;
|
||||
border: 1px dashed #38bdf8;
|
||||
`}
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
margin: 0 2px;
|
||||
}
|
||||
` : ""}
|
||||
`,
|
||||
plugins: [
|
||||
"autolink", "link", "lists", "code", "table", "image", "preview", "searchreplace", "wordcount",
|
||||
...(hasPlaceholders ? ["noneditable"] : [])
|
||||
].join(" "),
|
||||
toolbar: [
|
||||
"undo redo | bold italic underline |",
|
||||
"alignleft aligncenter alignright alignjustify |",
|
||||
"bullist numlist outdent indent | link image table | code preview",
|
||||
hasPlaceholders ? "| placeholders" : ""
|
||||
].join(" "),
|
||||
noneditable_class: "mceNonEditable",
|
||||
setup: (editor: TinyMCEEditorType) => {
|
||||
if (hasPlaceholders) {
|
||||
editor.ui.registry.addMenuButton("placeholders", {
|
||||
text: "Placeholders",
|
||||
fetch: (callback) => {
|
||||
const items = placeholders.map((p) => ({
|
||||
type: "menuitem" as const,
|
||||
text: p.label,
|
||||
onAction: () => {
|
||||
const html = `<span class="mceNonEditable placeholder" data-key="${p.key}">{{${p.key}}}</span>​`;
|
||||
editor.insertContent(html);
|
||||
},
|
||||
}));
|
||||
callback(items);
|
||||
},
|
||||
});
|
||||
|
||||
editor.on("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains("placeholder")) {
|
||||
editor.selection.select(target);
|
||||
}
|
||||
});
|
||||
|
||||
editor.on("keydown", (e) => {
|
||||
if (e.key === "Backspace" || e.key === "Delete") {
|
||||
const node = editor.selection.getNode();
|
||||
if (node && node.classList.contains("placeholder")) {
|
||||
e.preventDefault();
|
||||
node.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [placeholders, hasPlaceholders, isDarkMode]);
|
||||
|
||||
return (
|
||||
<TinyMCEEditor
|
||||
key={isDarkMode ? "dark" : "light"}
|
||||
tinymceScriptSrc="/tinymce/tinymce.min.js"
|
||||
apiKey={apiKey}
|
||||
value={value}
|
||||
onEditorChange={onEditorChange}
|
||||
init={init}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { CreateTemplateTypes } from "../types/templateTypes";
|
||||
import { Editor } from "@/record-management/common/editor/rte";
|
||||
import { TemplateEditor } from "./TemplateEditor";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
interface Props {
|
||||
template?: CreateTemplateTypes | null;
|
||||
isSubmitting: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmitCreate: (data: CreateTemplateTypes) => void;
|
||||
}
|
||||
|
||||
const editorApiKey = import.meta.env.VITE_EDITOR_API_KEY || "";
|
||||
|
||||
export const TemplateForm = ({
|
||||
template,
|
||||
isSubmitting,
|
||||
onCancel,
|
||||
onSubmitCreate,
|
||||
}: Props) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<CreateTemplateTypes>({
|
||||
defaultValues: {
|
||||
name: {
|
||||
en: template?.name?.en ?? "",
|
||||
am: template?.name?.am ?? "",
|
||||
},
|
||||
key: template?.key ?? "",
|
||||
subject: template?.subject ?? "",
|
||||
body: template?.body ?? "",
|
||||
sincerelyText: template?.sincerelyText ?? "",
|
||||
locale: template?.locale ?? "en",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: CreateTemplateTypes) => {
|
||||
onSubmitCreate(values);
|
||||
if (!template) {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 shadow-sm dark:shadow-gray-900/50 space-y-8"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Template Key
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Template Key"
|
||||
{...register("key", { required: "Template key is required" })}
|
||||
/>
|
||||
{errors.key && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.key.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Subject
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Subject"
|
||||
{...register("subject", { required: "Subject is required" })}
|
||||
/>
|
||||
{errors.subject && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{errors.subject.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
English Name
|
||||
</label>
|
||||
<Input
|
||||
placeholder="English Name"
|
||||
{...register("name.en", { required: "English name is required" })}
|
||||
/>
|
||||
{errors.name?.en && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{errors.name.en.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Amharic Name
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Amharic Name"
|
||||
{...register("name.am", { required: "Amharic name is required" })}
|
||||
/>
|
||||
{errors.name?.am && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{errors.name.am.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Language
|
||||
</label>
|
||||
<Controller
|
||||
name="locale"
|
||||
control={control}
|
||||
rules={{ required: "Language is required" }}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="en">English</SelectItem>
|
||||
<SelectItem value="am">Amharic</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errors.locale && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.locale.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Body
|
||||
</label>
|
||||
<Controller
|
||||
name="body"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<TemplateEditor
|
||||
value={field.value}
|
||||
onEditorChange={field.onChange}
|
||||
apiKey={editorApiKey}
|
||||
placeholders={[
|
||||
{ key: "delegatorName", label: "Delegator Name" },
|
||||
{ key: "delegatorDepartment", label: "Delegator Department" },
|
||||
{ key: "delegateeName", label: "Delegatee Name" },
|
||||
{ key: "delegateeDepartment", label: "Delegatee Department" },
|
||||
{ key: "startDate", label: "Start Date" },
|
||||
{ key: "endDate", label: "End Date" },
|
||||
{ key: "startDateTime", label: "Start Date & Time" },
|
||||
{ key: "endDateTime", label: "End Date & Time" },
|
||||
]}
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<p className="text-red-500 text-sm mt-1">Body is required</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Sincerely Text
|
||||
</label>
|
||||
<Input placeholder="Sincerely Text" {...register("sincerelyText")} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4 justify-end">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : template ? "Update" : "Save"}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,402 @@
|
||||
import React, { useState } from "react";
|
||||
import { sanitizeHtml } from "@/shared/lib/sanitize";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/common/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { FileText, Plus, Edit, Trash2, Eye } from "lucide-react";
|
||||
import { TemplateForm } from "./TemplateForm";
|
||||
import { CreateTemplateTypes } from "../types/templateTypes";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { toast } from "sonner";
|
||||
import { t } from "i18next";
|
||||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { useTemplate } from "../service/useTemplate";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
const TemplateTable = () => {
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const {
|
||||
templates,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
isCreatingTemplate,
|
||||
isUpdatingTemplate,
|
||||
isDeletingTemplate,
|
||||
} = useTemplate();
|
||||
|
||||
const templateList: CreateTemplateTypes[] = Array.isArray(templates) ? templates : templates?.items || [];
|
||||
|
||||
const localizedName = useLocalizedName();
|
||||
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] =
|
||||
useState<CreateTemplateTypes | null>(null);
|
||||
|
||||
const preventDialogCloseFromTinyMce = (event: Event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
|
||||
if (
|
||||
target?.closest(
|
||||
".tox-tinymce-aux, .moxman-window, .tam-assetmanager-root"
|
||||
)
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = (values: CreateTemplateTypes) => {
|
||||
createTemplate(values, {
|
||||
onSuccess: (newTemplate: any) => {
|
||||
// Assume API returns the created config or just general success
|
||||
toast.success(t("contentManagement.templateSuccessMsg"));
|
||||
refetch();
|
||||
setIsCreateDialogOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdate = (values: CreateTemplateTypes) => {
|
||||
if (!selectedTemplate?.id) return;
|
||||
|
||||
updateTemplate({ id: selectedTemplate.id, template: values }, {
|
||||
onSuccess: () => {
|
||||
toast.success(t("contentManagement.updateTemplate"));
|
||||
refetch();
|
||||
setIsEditDialogOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!selectedTemplate?.id) return;
|
||||
|
||||
deleteTemplate(selectedTemplate.id, {
|
||||
onSuccess: () => {
|
||||
toast.success(t("contentManagement.deleteTemplate"));
|
||||
refetch();
|
||||
setIsDeleteDialogOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openEditDialog = (template: CreateTemplateTypes) => {
|
||||
setSelectedTemplate(template);
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const openViewDialog = (template: CreateTemplateTypes) => {
|
||||
setSelectedTemplate(template);
|
||||
setIsViewDialogOpen(true);
|
||||
};
|
||||
|
||||
const openDeleteDialog = (template: CreateTemplateTypes) => {
|
||||
setSelectedTemplate(template);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
{t("contentManagement.letterTemplate")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{Array(3)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<Skeleton key={index} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
{t("contentManagement.letterTemplate")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-red-600">
|
||||
{t("contentManagement.failedToLoadTemplates")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
{t("contentManagement.letterTemplate")}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("contentManagement.createMsg")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("contentManagement.addTemplate")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{templateList.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<FileText className="h-12 w-12 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t("contentManagement.noTemplates")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[150px]">
|
||||
{t("common.name")}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px] hidden md:table-cell">
|
||||
{t("contentManagement.sincerelyText")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right min-w-[120px]">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{templateList.map((template) => (
|
||||
<TableRow key={template.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col">
|
||||
<span>{localizedName(template.name)}</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 md:hidden">
|
||||
{template.sincerelyText &&
|
||||
template.sincerelyText.length > 30
|
||||
? `${template.sincerelyText.substring(0, 30)}...`
|
||||
: template.sincerelyText ||
|
||||
t("common.notAvailable")}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate hidden md:table-cell">
|
||||
{template.sincerelyText || t("common.notAvailable")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openViewDialog(template)}
|
||||
title={t("contentManagement.viewTemplate")}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(template)}
|
||||
title={t("contentManagement.editTemplate")}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openDeleteDialog(template)}
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
||||
title={t("contentManagement.deleteTemplate")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] lg:max-w-[1200px] max-h-[90vh] overflow-y-auto w-full"
|
||||
onInteractOutside={preventDialogCloseFromTinyMce}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("contentManagement.createTemplate")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TemplateForm
|
||||
isSubmitting={isCreatingTemplate}
|
||||
onCancel={() => setIsCreateDialogOpen(false)}
|
||||
onSubmitCreate={handleCreate}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] lg:max-w-[1200px] max-h-[90vh] overflow-y-auto w-full"
|
||||
onInteractOutside={preventDialogCloseFromTinyMce}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("contentManagement.editTemplate")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TemplateForm
|
||||
template={selectedTemplate}
|
||||
isSubmitting={isUpdatingTemplate}
|
||||
onCancel={() => {
|
||||
setIsEditDialogOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
}}
|
||||
onSubmitCreate={handleUpdate}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* View Dialog */}
|
||||
<Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
|
||||
<DialogContent className="max-w-[95vw] lg:max-w-[1200px] max-h-[90vh] overflow-y-auto w-full">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("contentManagement.viewTemplate")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedTemplate && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{t("common.name")}
|
||||
</label>
|
||||
<p className="text-sm text-gray-900 dark:text-gray-100 mt-1">
|
||||
{localizedName(selectedTemplate.name)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{t("contentManagement.sincerelyText")}
|
||||
</label>
|
||||
<p className="text-sm text-gray-900 dark:text-gray-100 mt-1">
|
||||
{selectedTemplate.sincerelyText || t("common.notAvailable")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{t("contentManagement.body")}
|
||||
</label>
|
||||
<div
|
||||
className="text-sm text-gray-900 dark:text-gray-100 mt-1 p-3 border border-gray-200 dark:border-gray-700 rounded-md bg-gray-50 dark:bg-gray-700 max-h-60 overflow-y-auto"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtml(selectedTemplate.body) }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setIsViewDialogOpen(false)}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("contentManagement.deleteTemplate")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("contentManagement.deleteTemplateConfirm", {
|
||||
name: selectedTemplate
|
||||
? localizedName(selectedTemplate.name)
|
||||
: "",
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeletingTemplate}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isDeletingTemplate ? t("common.deleting") : t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplateTable;
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import TemplateTable from "./TemplateTable";
|
||||
|
||||
const TemplatePage = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-primary to-primary-400 bg-clip-text text-transparent">
|
||||
Manage Delegation Templates
|
||||
</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
Create, edit, and view delegation global templates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<TemplateTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TemplatePage;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
adoptDelegationTemplate,
|
||||
createDelegationTemplate,
|
||||
deleteDelegationTemplate,
|
||||
getDelegationTemplateById,
|
||||
getDelegationTemplates,
|
||||
updateDelegationTemplate,
|
||||
} from "@/super-admin/services/api/delegationTemplate";
|
||||
import { CreateTemplateTypes } from "../types/templateTypes";
|
||||
import { AdoptTemplateTypes } from "../template";
|
||||
|
||||
export const useTemplate = (id?: string) => {
|
||||
const {
|
||||
data: templates,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["templates"],
|
||||
queryFn: async () => {
|
||||
return await getDelegationTemplates();
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: template,
|
||||
isLoading: isLoadingTemplate,
|
||||
isError: isErrorTemplate,
|
||||
refetch: refetchTemplate,
|
||||
} = useQuery({
|
||||
queryKey: ["template", id],
|
||||
queryFn: async () => {
|
||||
return await getDelegationTemplateById(id!, { take: 300 });
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: createTemplate,
|
||||
isPending: isCreatingTemplate,
|
||||
isError: isErrorCreateTemplate,
|
||||
} = useMutation({
|
||||
mutationFn: async (template: CreateTemplateTypes) => {
|
||||
return await createDelegationTemplate(template);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: updateTemplate,
|
||||
isPending: isUpdatingTemplate,
|
||||
isError: isErrorUpdateTemplate,
|
||||
} = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
template,
|
||||
}: {
|
||||
id: string;
|
||||
template: CreateTemplateTypes;
|
||||
}) => {
|
||||
return await updateDelegationTemplate(id, template);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: deleteTemplate,
|
||||
isPending: isDeletingTemplate,
|
||||
isError: isErrorDeleteTemplate,
|
||||
} = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
return await deleteDelegationTemplate(id);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: adoptTemplate,
|
||||
isPending: isAdoptingTemplate,
|
||||
isError: isErrorAdoptingTemplate,
|
||||
} = useMutation({
|
||||
mutationFn: async (item: AdoptTemplateTypes) => {
|
||||
return await adoptDelegationTemplate(item);
|
||||
},
|
||||
});
|
||||
return {
|
||||
templates,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
template,
|
||||
isLoadingTemplate,
|
||||
isErrorTemplate,
|
||||
refetchTemplate,
|
||||
createTemplate,
|
||||
isCreatingTemplate,
|
||||
isErrorCreateTemplate,
|
||||
updateTemplate,
|
||||
isUpdatingTemplate,
|
||||
isErrorUpdateTemplate,
|
||||
deleteTemplate,
|
||||
isDeletingTemplate,
|
||||
isErrorDeleteTemplate,
|
||||
adoptTemplate,
|
||||
isAdoptingTemplate,
|
||||
isErrorAdoptingTemplate,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface CreateTemplateTypes {
|
||||
sincerelyText: string,
|
||||
body: string,
|
||||
subject: string,
|
||||
key: string,
|
||||
name: {
|
||||
am: string,
|
||||
en: string
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export interface AdoptTemplateTypes {
|
||||
templateId: string,
|
||||
headerId: string,
|
||||
footerId: string,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface CreateTemplateTypes {
|
||||
id?: string;
|
||||
sincerelyText: string;
|
||||
body: string;
|
||||
subject: string;
|
||||
key: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
locale: "am" | "en";
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export type UserStatus = "pending" | "accepted" | "rejected" | "submitted"; // Based on backend API response
|
||||
|
||||
export interface LocalizedName {
|
||||
am: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
export interface ExternalUserDto {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
name: LocalizedName;
|
||||
username: string;
|
||||
phoneNumber: string;
|
||||
email: string;
|
||||
userType: "external_organization" | "employee" | "individual";
|
||||
sharepointId: string | null;
|
||||
status: UserStatus;
|
||||
isActive: boolean;
|
||||
hasSetPassword: boolean;
|
||||
isPhoneNumberVerified: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalUsersResponseDto {
|
||||
count: number;
|
||||
items: ExternalUserDto[];
|
||||
}
|
||||
|
||||
export enum UserType {
|
||||
NON_SMART_USER = "non_smart_user",
|
||||
INTERNAL = "internal",
|
||||
}
|
||||
|
||||
export interface UserTypeDto {
|
||||
type: UserType;
|
||||
count: number;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface SiteNameDto {
|
||||
am: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
export interface SiteDto {
|
||||
id: string;
|
||||
name: SiteNameDto;
|
||||
key: string;
|
||||
domain: string;
|
||||
status: "Active" | "Archived" | string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface SitePayloadDto {
|
||||
name: SiteNameDto;
|
||||
key: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export interface SitesResponseDto {
|
||||
items: SiteDto[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SiteQueryParams {
|
||||
orderBy?: string;
|
||||
take?: number;
|
||||
skip?: number;
|
||||
order?: string;
|
||||
search?: string;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface MetadataDTO {
|
||||
uploadedBy: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
deliveredBy: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
organizationName: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContentDTO {
|
||||
id: string;
|
||||
subject: string;
|
||||
receiverEmployees: any[]; // replace `any` with proper type if known
|
||||
receiverPositions: any[]; // replace `any` with proper type if known
|
||||
}
|
||||
|
||||
export interface RecordDTO {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
id: string;
|
||||
referenceNumber: string;
|
||||
letterNumber: string;
|
||||
metadata: MetadataDTO;
|
||||
dispatchedDate: string;
|
||||
amharicDispatchedDate: string;
|
||||
amharicCreatedAt: string;
|
||||
statusKey: string;
|
||||
recordTypeKey: string;
|
||||
initiator: string | null;
|
||||
content: ContentDTO[];
|
||||
}
|
||||
|
||||
export interface MainDTO {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
id: string;
|
||||
amharicCreatedAt: string;
|
||||
status: string;
|
||||
record: RecordDTO;
|
||||
}
|
||||
|
||||
export interface MainDtoResponse {
|
||||
items:MainDTO[];
|
||||
count:number;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ItemDTO, User } from "@/shared/dto/user/usersDto";
|
||||
import { getArchivedUserId, OrgQueryParams } from "@/shared/services/organizationsService";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export const useArchivedUsers = (UnitId: string,params?:OrgQueryParams) => {
|
||||
return useQuery({
|
||||
queryKey: ["archived-users", UnitId,params],
|
||||
queryFn: async () => {
|
||||
if (!UnitId) return { items: [], count: 0 };
|
||||
const { data } = await getArchivedUserId(UnitId,params);
|
||||
return data as { items: ItemDTO[]; count: number };
|
||||
},
|
||||
enabled: !!UnitId,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import {
|
||||
createGlobalOrgConfig,
|
||||
updateGlobalOrgConfig,
|
||||
getGlobalOrgConfig,
|
||||
getListOfGlobalOrgConfig,
|
||||
createGlobalUnitConfig,
|
||||
updateGlobalUnitConfig,
|
||||
deleteGlobalUnitConfig,
|
||||
getGlobalUnitConfig,
|
||||
getListOfGlobalUnitConfig,
|
||||
GlobalOrgConfig,
|
||||
GlobalUnitConfig,
|
||||
createOrganizationConfig,
|
||||
updateOrganizationConfig,
|
||||
deleteOrganizationConfig,
|
||||
getOrganizationConfig,
|
||||
OrganizationConfigPayload,
|
||||
} from "@/shared/services/organizationConfigService";
|
||||
|
||||
// 🔹 Global Org Config Hooks
|
||||
export const useGetGlobalOrgConfig = (id: string) => {
|
||||
return useQuery<AxiosResponse>({
|
||||
queryKey: ["globalOrgConfig", id],
|
||||
queryFn: () => getGlobalOrgConfig(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetListOfGlobalOrgConfig = (id: string) => {
|
||||
return useQuery<AxiosResponse>({
|
||||
queryKey: ["listGlobalOrgConfig", id],
|
||||
queryFn: () => getListOfGlobalOrgConfig(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateGlobalOrgConfig = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: GlobalOrgConfig) => createGlobalOrgConfig(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["listGlobalOrgConfig"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateGlobalOrgConfig = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: GlobalOrgConfig }) =>
|
||||
updateGlobalOrgConfig(id, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["globalOrgConfig", variables.id],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["listGlobalOrgConfig"],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 🔹 Global Unit Config Hooks
|
||||
export const useGetGlobalUnitConfig = (id: string) => {
|
||||
return useQuery<AxiosResponse>({
|
||||
queryKey: ["globalUnitConfig", id],
|
||||
queryFn: () => getGlobalUnitConfig(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetListOfGlobalUnitConfig = (id: string) => {
|
||||
return useQuery<AxiosResponse>({
|
||||
queryKey: ["listGlobalUnitConfig", id],
|
||||
queryFn: () => getListOfGlobalUnitConfig(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateGlobalUnitConfig = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: any) => createGlobalUnitConfig(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["listGlobalUnitConfig"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateGlobalUnitConfig = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) =>
|
||||
updateGlobalUnitConfig(id, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["globalUnitConfig", variables.id],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["listGlobalUnitConfig"],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteGlobalUnitConfig = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteGlobalUnitConfig(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["listGlobalUnitConfig"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["globalUnitConfig"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 🔹 Organization Configuration Hooks (/organization-configurations)
|
||||
export const useGetOrganizationConfig = (organizationId: string) => {
|
||||
return useQuery<AxiosResponse>({
|
||||
queryKey: ["organizationConfig", organizationId],
|
||||
queryFn: () => getOrganizationConfig(organizationId),
|
||||
enabled: !!organizationId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateOrganizationConfig = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: OrganizationConfigPayload) =>
|
||||
createOrganizationConfig(data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["organizationConfig", variables.organizationId],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateOrganizationConfig = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: string;
|
||||
data: OrganizationConfigPayload;
|
||||
}) => updateOrganizationConfig(id, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["organizationConfig", variables.data.organizationId],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteOrganizationConfig = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteOrganizationConfig(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["organizationConfig"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import { OrganizationDto } from "@/shared/dto/organization/organizationDto";
|
||||
import { getOrganizations } from "@/shared/services/organizationsService";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Cookies from "js-cookie";
|
||||
import { toast } from "sonner";
|
||||
import { useOrganizations } from "./useOrganizations";
|
||||
|
||||
// Types for dashboard data
|
||||
export interface DashboardStats {
|
||||
totalOrganizations: number;
|
||||
activeOrganizations: number;
|
||||
inactiveOrganizations: number;
|
||||
pendingRequests: number;
|
||||
orgAdmins: number;
|
||||
recentActivities: Activity[];
|
||||
percentChanges: {
|
||||
orgs: number;
|
||||
active: number;
|
||||
inactive: number;
|
||||
pending: number;
|
||||
admins: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
user: string;
|
||||
timestamp: string;
|
||||
entity: string;
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
// Mock data
|
||||
const mockDashboardData: DashboardStats = {
|
||||
totalOrganizations: 48,
|
||||
activeOrganizations: 35,
|
||||
inactiveOrganizations: 13,
|
||||
pendingRequests: 7,
|
||||
orgAdmins: 21,
|
||||
recentActivities: [
|
||||
{
|
||||
id: "1",
|
||||
type: "CREATE",
|
||||
description: "Created new organization",
|
||||
user: "John Doe",
|
||||
timestamp: "2023-07-15T10:30:00Z",
|
||||
entity: "Woreda 01",
|
||||
entityId: "org-1",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "UPDATE",
|
||||
description: "Updated organization status",
|
||||
user: "Jane Smith",
|
||||
timestamp: "2023-07-14T14:45:00Z",
|
||||
entity: "Woreda 02",
|
||||
entityId: "org-2",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
type: "DELETE",
|
||||
description: "Deleted organization",
|
||||
user: "Admin User",
|
||||
timestamp: "2023-07-13T09:15:00Z",
|
||||
entity: "Test Org",
|
||||
entityId: "org-3",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
type: "CREATE",
|
||||
description: "Added admin user",
|
||||
user: "System",
|
||||
timestamp: "2023-07-12T16:20:00Z",
|
||||
entity: "Samuel Bekele",
|
||||
entityId: "user-1",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
type: "UPDATE",
|
||||
description: "Changed organization settings",
|
||||
user: "Jane Smith",
|
||||
timestamp: "2023-07-11T11:05:00Z",
|
||||
entity: "Yeka Subcity",
|
||||
entityId: "org-4",
|
||||
},
|
||||
],
|
||||
percentChanges: {
|
||||
orgs: 12.5,
|
||||
active: 8.2,
|
||||
inactive: -3.7,
|
||||
pending: -15.4,
|
||||
admins: 5.6,
|
||||
},
|
||||
};
|
||||
|
||||
const fetchDashboardStats = async (
|
||||
totalOrganizations: number
|
||||
): Promise<DashboardStats> => {
|
||||
// Use mock data for now, but replace totalOrganizations with the actual count
|
||||
const dashboardData = { ...mockDashboardData };
|
||||
dashboardData.totalOrganizations =
|
||||
totalOrganizations || dashboardData.totalOrganizations;
|
||||
return dashboardData;
|
||||
};
|
||||
|
||||
const fetchRecentOrganizations = async (): Promise<OrganizationDto[]> => {
|
||||
try {
|
||||
const tenantKey = Cookies.get("tenant-key");
|
||||
if (!tenantKey) {
|
||||
console.error("No tenant key found");
|
||||
return [];
|
||||
}
|
||||
|
||||
const headers = {
|
||||
tenantKey,
|
||||
unitId: Cookies.get("unit-id") || undefined,
|
||||
};
|
||||
|
||||
const { data } = await getOrganizations({
|
||||
orderBy: "createdAt",
|
||||
take: 5,
|
||||
});
|
||||
|
||||
return data.items;
|
||||
} catch (error) {
|
||||
console.error("Error fetching recent organizations:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const useDashboardData = () => {
|
||||
const { organizationsResponse } = useOrganizations("Org");
|
||||
|
||||
const {
|
||||
data: stats,
|
||||
isLoading: isStatsLoading,
|
||||
isError: isStatsError,
|
||||
refetch: refetchStats,
|
||||
} = useQuery({
|
||||
queryKey: ["dashboardStats"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const totalOrganizations = organizationsResponse?.count || 0;
|
||||
return await fetchDashboardStats(totalOrganizations);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch dashboard statistics:", error);
|
||||
toast.error("Failed to fetch dashboard statistics");
|
||||
return mockDashboardData;
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const {
|
||||
data: recentOrgs,
|
||||
isLoading: isOrgsLoading,
|
||||
isError: isOrgsError,
|
||||
refetch: refetchOrgs,
|
||||
} = useQuery({
|
||||
queryKey: ["recentOrganizations"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await fetchRecentOrganizations();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch recent organizations:", error);
|
||||
toast.error("Failed to fetch recent organizations");
|
||||
return [];
|
||||
}
|
||||
},
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
const isLoading = isStatsLoading || isOrgsLoading;
|
||||
const isError = isStatsError || isOrgsError;
|
||||
|
||||
const refetch = () => {
|
||||
refetchStats();
|
||||
refetchOrgs();
|
||||
};
|
||||
|
||||
const orgCount = organizationsResponse?.count;
|
||||
|
||||
return {
|
||||
stats: stats || orgCount,
|
||||
recentOrgs: recentOrgs || [],
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
import { Position } from "./usePositions";
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
id: string;
|
||||
name: string;
|
||||
positions: Position[];
|
||||
isExpanded?: boolean;
|
||||
parentDepartmentId?: string;
|
||||
}
|
||||
|
||||
export const useDepartments = (unitId: string) => {
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [selectedDepartmentId, setSelectedDepartmentId] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Fetch departments for a unit
|
||||
const fetchDepartments = async () => {
|
||||
if (!unitId) {
|
||||
setDepartments([]);
|
||||
setSelectedDepartmentId("");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
|
||||
setTimeout(() => {
|
||||
// Mock departments for this unit
|
||||
const mockDepartments: Department[] = [
|
||||
{
|
||||
id: "dept1",
|
||||
name: "Directorate",
|
||||
positions: [],
|
||||
isExpanded: true,
|
||||
},
|
||||
{
|
||||
id: "dept2",
|
||||
name: "Development",
|
||||
positions: [],
|
||||
isExpanded: false,
|
||||
},
|
||||
{
|
||||
id: "dept3",
|
||||
name: "Operations",
|
||||
positions: [],
|
||||
isExpanded: false,
|
||||
},
|
||||
];
|
||||
|
||||
setDepartments(mockDepartments);
|
||||
if (mockDepartments.length > 0) {
|
||||
setSelectedDepartmentId(mockDepartments[0].id);
|
||||
} else {
|
||||
setSelectedDepartmentId("");
|
||||
}
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err : new Error("Failed to fetch departments")
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new department
|
||||
const createDepartment = async (
|
||||
departmentData: Partial<Department>
|
||||
): Promise<Department> => {
|
||||
if (!unitId) {
|
||||
throw new Error("No unit selected");
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// const response = await axios.post<ApiResponse<Department>>(
|
||||
// `/api/units/${unitId}/departments`,
|
||||
// departmentData
|
||||
// );
|
||||
// const newDepartment = response.data.data;
|
||||
|
||||
// For now, simulate API response
|
||||
const newDepartment: Department = {
|
||||
id: `dept-${Date.now()}`,
|
||||
name: departmentData.name || "New Department",
|
||||
positions: [],
|
||||
isExpanded: false,
|
||||
parentDepartmentId: departmentData.parentDepartmentId,
|
||||
};
|
||||
|
||||
setDepartments((prev) => [...prev, newDepartment]);
|
||||
setLoading(false);
|
||||
return newDepartment;
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err : new Error("Failed to create department")
|
||||
);
|
||||
setLoading(false);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Update an existing department
|
||||
const updateDepartment = async (
|
||||
departmentId: string,
|
||||
updates: Partial<Department>
|
||||
): Promise<Department> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// const response = await axios.put<ApiResponse<Department>>(
|
||||
// `/api/departments/${departmentId}`,
|
||||
// updates
|
||||
// );
|
||||
// const updatedDepartment = response.data.data;
|
||||
|
||||
// For now, simulate API response
|
||||
const updatedDepartments = departments.map((department) =>
|
||||
department.id === departmentId
|
||||
? { ...department, ...updates }
|
||||
: department
|
||||
);
|
||||
|
||||
setDepartments(updatedDepartments);
|
||||
setLoading(false);
|
||||
|
||||
const updatedDepartment = updatedDepartments.find(
|
||||
(d) => d.id === departmentId
|
||||
);
|
||||
if (!updatedDepartment) {
|
||||
throw new Error("Department not found after update");
|
||||
}
|
||||
|
||||
return updatedDepartment;
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err : new Error("Failed to update department")
|
||||
);
|
||||
setLoading(false);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a department
|
||||
const deleteDepartment = async (departmentId: string): Promise<void> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// await axios.delete<ApiResponse<void>>(`/api/departments/${departmentId}`);
|
||||
|
||||
// For now, simulate API response
|
||||
setDepartments((prev) =>
|
||||
prev.filter((department) => department.id !== departmentId)
|
||||
);
|
||||
|
||||
// If the deleted department was selected, select another department
|
||||
if (selectedDepartmentId === departmentId) {
|
||||
const remaining = departments.filter(
|
||||
(department) => department.id !== departmentId
|
||||
);
|
||||
if (remaining.length > 0) {
|
||||
setSelectedDepartmentId(remaining[0].id);
|
||||
} else {
|
||||
setSelectedDepartmentId("");
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err : new Error("Failed to delete department")
|
||||
);
|
||||
setLoading(false);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Add a sub-department
|
||||
const addSubDepartment = async (
|
||||
parentDepartmentId: string,
|
||||
name: string
|
||||
): Promise<Department> => {
|
||||
const subdepartment: Partial<Department> = {
|
||||
name,
|
||||
parentDepartmentId,
|
||||
};
|
||||
|
||||
return createDepartment(subdepartment);
|
||||
};
|
||||
|
||||
// Toggle expand/collapse for a department
|
||||
const toggleDepartmentExpand = (departmentId: string) => {
|
||||
setDepartments((prev) =>
|
||||
prev.map((department) =>
|
||||
department.id === departmentId
|
||||
? { ...department, isExpanded: !department.isExpanded }
|
||||
: department
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Get root-level departments (no parent)
|
||||
const getRootDepartments = () => {
|
||||
return departments.filter((department) => !department.parentDepartmentId);
|
||||
};
|
||||
|
||||
// Get sub-departments of a department
|
||||
const getSubDepartments = (parentDepartmentId: string) => {
|
||||
return departments.filter(
|
||||
(department) => department.parentDepartmentId === parentDepartmentId
|
||||
);
|
||||
};
|
||||
|
||||
// Select a department
|
||||
const selectDepartment = (departmentId: string) => {
|
||||
setSelectedDepartmentId(departmentId);
|
||||
};
|
||||
|
||||
// Load departments when unitId changes
|
||||
useEffect(() => {
|
||||
fetchDepartments();
|
||||
}, [unitId]);
|
||||
|
||||
return {
|
||||
departments,
|
||||
selectedDepartmentId,
|
||||
selectedDepartment: departments.find(
|
||||
(department) => department.id === selectedDepartmentId
|
||||
),
|
||||
loading,
|
||||
error,
|
||||
fetchDepartments,
|
||||
createDepartment,
|
||||
updateDepartment,
|
||||
deleteDepartment,
|
||||
addSubDepartment,
|
||||
toggleDepartmentExpand,
|
||||
getRootDepartments,
|
||||
getSubDepartments,
|
||||
selectDepartment,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
export type InviteStatus = "Pending" | "Accepted" | "Not Invited";
|
||||
export type EmployeeRole =
|
||||
| "TeamLeader"
|
||||
| "TeamMember"
|
||||
| "Director"
|
||||
| "Manager"
|
||||
| "Regular";
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface Employee {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
role?: EmployeeRole;
|
||||
inviteStatus?: InviteStatus;
|
||||
title?: string;
|
||||
phone?: string;
|
||||
dateAdded?: string;
|
||||
}
|
||||
|
||||
export const useEmployees = (positionId: string, departmentId?: string) => {
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Fetch employees for a position or department
|
||||
const fetchEmployees = async () => {
|
||||
if (!positionId && !departmentId) {
|
||||
setEmployees([]);
|
||||
setSelectedEmployeeId("");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// if (positionId) {
|
||||
// const response = await axios.get<ApiResponse<Employee[]>>(`/api/positions/${positionId}/employees`);
|
||||
// setEmployees(response.data.data);
|
||||
// } else if (departmentId) {
|
||||
// const response = await axios.get<ApiResponse<Employee[]>>(`/api/departments/${departmentId}/employees`);
|
||||
// setEmployees(response.data.data);
|
||||
// }
|
||||
|
||||
// For now, simulate API response with mock data
|
||||
setTimeout(() => {
|
||||
const mockEmployees: Employee[] = [
|
||||
{
|
||||
id: "emp1",
|
||||
name: "Abebe Kebede",
|
||||
email: "abebe@example.com",
|
||||
role: "Director",
|
||||
title: "Department Director",
|
||||
phone: "+251911123456",
|
||||
dateAdded: "2023-05-15",
|
||||
inviteStatus: "Accepted",
|
||||
},
|
||||
{
|
||||
id: "emp2",
|
||||
name: "Kebede Abebe",
|
||||
email: "kebede@example.com",
|
||||
role: "TeamLeader",
|
||||
title: "Team Lead - Development",
|
||||
phone: "+251922123456",
|
||||
dateAdded: "2023-06-20",
|
||||
inviteStatus: "Pending",
|
||||
},
|
||||
{
|
||||
id: "emp3",
|
||||
name: "Chala Demeke",
|
||||
email: "chala@example.com",
|
||||
role: "TeamMember",
|
||||
title: "Senior Developer",
|
||||
phone: "+251933123456",
|
||||
dateAdded: "2023-07-10",
|
||||
inviteStatus: "Not Invited",
|
||||
},
|
||||
{
|
||||
id: "emp4",
|
||||
name: "Tigist Alemu",
|
||||
email: "tigist@example.com",
|
||||
role: "Manager",
|
||||
title: "Project Manager",
|
||||
phone: "+251944123456",
|
||||
dateAdded: "2023-08-05",
|
||||
inviteStatus: "Accepted",
|
||||
},
|
||||
];
|
||||
|
||||
setEmployees(mockEmployees);
|
||||
if (mockEmployees.length > 0) {
|
||||
setSelectedEmployeeId(mockEmployees[0].id);
|
||||
} else {
|
||||
setSelectedEmployeeId("");
|
||||
}
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err : new Error("Failed to fetch employees")
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new employee
|
||||
const createEmployee = async (
|
||||
employeeData: Partial<Employee>
|
||||
): Promise<Employee> => {
|
||||
if (!positionId && !departmentId) {
|
||||
throw new Error("No position or department selected");
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// let response;
|
||||
// if (positionId) {
|
||||
// response = await axios.post<ApiResponse<Employee>>(
|
||||
// `/api/positions/${positionId}/employees`,
|
||||
// employeeData
|
||||
// );
|
||||
// } else if (departmentId) {
|
||||
// response = await axios.post<ApiResponse<Employee>>(
|
||||
// `/api/departments/${departmentId}/employees`,
|
||||
// employeeData
|
||||
// );
|
||||
// } else {
|
||||
// throw new Error('No position or department ID provided');
|
||||
// }
|
||||
// const newEmployee = response.data.data;
|
||||
|
||||
// For now, simulate API response
|
||||
const newEmployee: Employee = {
|
||||
id: `emp-${Date.now()}`,
|
||||
name: employeeData.name || "New Employee",
|
||||
email: employeeData.email,
|
||||
role: employeeData.role || "Regular",
|
||||
title: employeeData.title,
|
||||
phone: employeeData.phone,
|
||||
dateAdded: new Date().toISOString().split("T")[0],
|
||||
inviteStatus: employeeData.inviteStatus || "Not Invited",
|
||||
};
|
||||
|
||||
setEmployees((prev) => [...prev, newEmployee]);
|
||||
setLoading(false);
|
||||
return newEmployee;
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err : new Error("Failed to create employee")
|
||||
);
|
||||
setLoading(false);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Update an existing employee
|
||||
const updateEmployee = async (
|
||||
employeeId: string,
|
||||
updates: Partial<Employee>
|
||||
): Promise<Employee> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// const response = await axios.put<ApiResponse<Employee>>(
|
||||
// `/api/employees/${employeeId}`,
|
||||
// updates
|
||||
// );
|
||||
// const updatedEmployee = response.data.data;
|
||||
|
||||
// For now, simulate API response
|
||||
const updatedEmployees = employees.map((employee) =>
|
||||
employee.id === employeeId ? { ...employee, ...updates } : employee
|
||||
);
|
||||
|
||||
setEmployees(updatedEmployees);
|
||||
setLoading(false);
|
||||
|
||||
const updatedEmployee = updatedEmployees.find((e) => e.id === employeeId);
|
||||
if (!updatedEmployee) {
|
||||
throw new Error("Employee not found after update");
|
||||
}
|
||||
|
||||
return updatedEmployee;
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err : new Error("Failed to update employee")
|
||||
);
|
||||
setLoading(false);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete an employee
|
||||
const deleteEmployee = async (employeeId: string): Promise<void> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// await axios.delete<ApiResponse<void>>(`/api/employees/${employeeId}`);
|
||||
|
||||
// For now, simulate API response
|
||||
setEmployees((prev) =>
|
||||
prev.filter((employee) => employee.id !== employeeId)
|
||||
);
|
||||
|
||||
// If the deleted employee was selected, select another employee
|
||||
if (selectedEmployeeId === employeeId) {
|
||||
const remaining = employees.filter(
|
||||
(employee) => employee.id !== employeeId
|
||||
);
|
||||
if (remaining.length > 0) {
|
||||
setSelectedEmployeeId(remaining[0].id);
|
||||
} else {
|
||||
setSelectedEmployeeId("");
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err : new Error("Failed to delete employee")
|
||||
);
|
||||
setLoading(false);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Send invitation to an employee
|
||||
const inviteEmployee = async (employeeId: string): Promise<string> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// const response = await axios.post<ApiResponse<{ inviteLink: string }>>(
|
||||
// `/api/employees/${employeeId}/invite`
|
||||
// );
|
||||
// return response.data.data.inviteLink;
|
||||
|
||||
// For now, simulate API response
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const inviteLink = `https://example.com/invite/${employeeId}-${Date.now()}`;
|
||||
|
||||
// Update employee status in state
|
||||
setEmployees((prev) =>
|
||||
prev.map((employee) =>
|
||||
employee.id === employeeId
|
||||
? { ...employee, inviteStatus: "Pending" as InviteStatus }
|
||||
: employee
|
||||
)
|
||||
);
|
||||
|
||||
setLoading(false);
|
||||
resolve(inviteLink);
|
||||
}, 1000);
|
||||
});
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err : new Error("Failed to send invitation")
|
||||
);
|
||||
setLoading(false);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Get invite status for an employee
|
||||
const getInviteStatus = async (employeeId: string): Promise<InviteStatus> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// const response = await axios.get<ApiResponse<{ status: InviteStatus }>>(
|
||||
// `/api/employees/${employeeId}/invite-status`
|
||||
// );
|
||||
// return response.data.data.status;
|
||||
|
||||
// For now, simulate API response
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const employee = employees.find((e) => e.id === employeeId);
|
||||
setLoading(false);
|
||||
resolve(employee?.inviteStatus || "Not Invited");
|
||||
}, 500);
|
||||
});
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err
|
||||
: new Error("Failed to get invitation status")
|
||||
);
|
||||
setLoading(false);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Select an employee
|
||||
const selectEmployee = (employeeId: string) => {
|
||||
setSelectedEmployeeId(employeeId);
|
||||
};
|
||||
|
||||
// Filter employees by role
|
||||
const getEmployeesByRole = (role: EmployeeRole) => {
|
||||
return employees.filter((employee) => employee.role === role);
|
||||
};
|
||||
|
||||
// Filter employees by invite status
|
||||
const getEmployeesByInviteStatus = (status: InviteStatus) => {
|
||||
return employees.filter((employee) => employee.inviteStatus === status);
|
||||
};
|
||||
|
||||
// Load employees when positionId or departmentId changes
|
||||
useEffect(() => {
|
||||
fetchEmployees();
|
||||
}, [positionId, departmentId]);
|
||||
|
||||
return {
|
||||
employees,
|
||||
selectedEmployeeId,
|
||||
selectedEmployee: employees.find(
|
||||
(employee) => employee.id === selectedEmployeeId
|
||||
),
|
||||
loading,
|
||||
error,
|
||||
fetchEmployees,
|
||||
createEmployee,
|
||||
updateEmployee,
|
||||
deleteEmployee,
|
||||
inviteEmployee,
|
||||
getInviteStatus,
|
||||
selectEmployee,
|
||||
getEmployeesByRole,
|
||||
getEmployeesByInviteStatus,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
activateUser,
|
||||
approveUser,
|
||||
deactivateUser,
|
||||
deleteUser,
|
||||
getAllExternalUsers,
|
||||
getPendingUsers,
|
||||
} from "../services/api/userService";
|
||||
|
||||
// 📦 Query Key
|
||||
const queryKeys = {
|
||||
pendingUsers: ["external-users", "pending"],
|
||||
allExternalUsers: ["external-users", "all"],
|
||||
};
|
||||
|
||||
export enum userTypeEnum {
|
||||
external = "external_organization",
|
||||
individual = "individual",
|
||||
externalUsers = "external_organization,individual",
|
||||
}
|
||||
|
||||
export interface ExternalQueryParams {
|
||||
orderBy?: string;
|
||||
take?: number;
|
||||
skip?: number;
|
||||
order?: string;
|
||||
userType?: userTypeEnum;
|
||||
name?: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
phoneNumber?: string;
|
||||
}
|
||||
|
||||
// ✅ Hook: Get pending external users
|
||||
export const usePendingExternalUsers = (params?: ExternalQueryParams) => {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.pendingUsers,
|
||||
queryFn: async () => {
|
||||
const users = await getPendingUsers(params);
|
||||
return users.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ✅ Hook: Get all external users
|
||||
export const useAllExternalUsers = (params?: ExternalQueryParams) => {
|
||||
return useQuery({
|
||||
queryKey: [...queryKeys.allExternalUsers, params],
|
||||
queryFn: async () => {
|
||||
const users = await getAllExternalUsers(params);
|
||||
return users.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ✅ Mutation: Activate user
|
||||
export const useActivateExternalUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => activateUser(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pendingUsers });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.allExternalUsers });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Mutatae Approve user
|
||||
export const useApproveExternalUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: string }) =>
|
||||
approveUser(id, status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pendingUsers });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.allExternalUsers });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// ✅ Mutation: Deactivate user
|
||||
export const useDeactivateExternalUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deactivateUser(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pendingUsers });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.allExternalUsers });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ✅ Mutation: Delete user
|
||||
export const useDeleteExternalUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteUser(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.pendingUsers });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.allExternalUsers });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MainDtoResponse } from "../dto/SuperAdminDto";
|
||||
import { getAllRecords } from "../services/api/userService";
|
||||
import { ExternalQueryParams } from "./useExternalUsers";
|
||||
|
||||
export const useMigratedData = (unitId: string,params?:ExternalQueryParams) => {
|
||||
return useQuery({
|
||||
queryKey: ["migratedData",unitId,params],
|
||||
queryFn: async () => {
|
||||
const { data } = await getAllRecords(unitId,params);
|
||||
return data as MainDtoResponse;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import { resendVerificationCode } from "@/shared/services/authService";
|
||||
import {
|
||||
assignOrganizationAdmin,
|
||||
assignUnitAdmin,
|
||||
fetchAllUnitAdminById,
|
||||
fetchUnitAdminById,
|
||||
getOrganizationAdminById,
|
||||
getOrganizationAdmins,
|
||||
OrgAdminQueryParams,
|
||||
OrganizationAdminPayload,
|
||||
UnitAdminPayload,
|
||||
updateOrganizationAdmin,
|
||||
} from "@/super-admin/services/api/organizationAdminService";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AxiosError } from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
// Resend verification code API call
|
||||
const resendVerificationCodeToAdmin = async ({
|
||||
organizationId,
|
||||
email,
|
||||
phoneNumber,
|
||||
}: {
|
||||
organizationId: string;
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
}) => {
|
||||
const response = await fetch("/api/resend-verification-code", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ organizationId, email, phoneNumber }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to resend verification code");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const useOrganizationAdmins = (params?: OrgAdminQueryParams) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
// Get all admins
|
||||
const {
|
||||
data: adminsResponse,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["organizationAdmins", params],
|
||||
queryFn: async () => {
|
||||
const { data } = await getOrganizationAdmins(params);
|
||||
return {
|
||||
count: data,
|
||||
items: data,
|
||||
};
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
// Create admin
|
||||
const { mutate: createAdminMutation, isPending: isCreating } = useMutation({
|
||||
mutationFn: async ({
|
||||
payload,
|
||||
successCallback,
|
||||
}: {
|
||||
payload: OrganizationAdminPayload;
|
||||
successCallback: () => void;
|
||||
}) => assignOrganizationAdmin(payload),
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success("Admin assigned successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] });
|
||||
variables.successCallback();
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
const { mutate: createUnitAdminMutation, isPending: isUnitCreating } = useMutation({
|
||||
mutationFn: async ({
|
||||
payload,
|
||||
successCallback,
|
||||
}: {
|
||||
payload: UnitAdminPayload;
|
||||
successCallback: () => void;
|
||||
}) => assignUnitAdmin(payload),
|
||||
onSuccess: (_data, variables) => {
|
||||
toast.success("Admin assigned successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] });
|
||||
variables.successCallback();
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
// Update admin
|
||||
const { mutate: updateAdminMutation, isPending: isUpdating } = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: string;
|
||||
data: OrganizationAdminPayload;
|
||||
}) => updateOrganizationAdmin(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success("Admin updated successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
// Get admin by ID
|
||||
const getAdminById = async (id: string) => {
|
||||
try {
|
||||
return await getOrganizationAdminById(id);
|
||||
} catch (error) {
|
||||
toast.error("Failed to fetch admin details");
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const getUnitAdminById = async (id: string) => {
|
||||
try {
|
||||
return await fetchUnitAdminById(id);
|
||||
} catch (error) {
|
||||
toast.error("Failed to fetch unit admin details");
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getAllAdminById = async (id: string, params?: OrgAdminQueryParams) => {
|
||||
try {
|
||||
return await fetchAllUnitAdminById(id, params);
|
||||
} catch (error) {
|
||||
toast.error("Failed to fetch all admin details");
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Resend invitation (verification code)
|
||||
const resendInvitation = async ({
|
||||
email,
|
||||
phoneNumber,
|
||||
}: {
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
}) => {
|
||||
try {
|
||||
await resendVerificationCode({
|
||||
email,
|
||||
phoneNumber,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to resend verification code:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
adminsResponse,
|
||||
createAdmin: createAdminMutation,
|
||||
createUnitAdmin:createUnitAdminMutation,
|
||||
updateAdmin: updateAdminMutation,
|
||||
getAdminById,
|
||||
getUnitAdminById,
|
||||
resendInvitation,
|
||||
getAllAdminById,// 👈 important to expose this
|
||||
isCreating,
|
||||
isUpdating,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getOrganizationTypes,
|
||||
OrgTypeResponse,
|
||||
OrgType,
|
||||
} from "@/super-admin/services/api/organizationTypesService";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
// Mock data to use when the API fails
|
||||
const MOCK_ORGANIZATION_TYPES: OrgType[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: {
|
||||
en: "Government",
|
||||
am: "መንግስታዊ",
|
||||
},
|
||||
key: "government",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: {
|
||||
en: "Private",
|
||||
am: "የግል",
|
||||
},
|
||||
key: "private",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: {
|
||||
en: "Non-Governmental",
|
||||
am: "መንግስታዊ ያልሆነ",
|
||||
},
|
||||
key: "ngo",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
export const useOrganizationTypes = () => {
|
||||
const {
|
||||
data: organizationTypesResponse,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery<OrgTypeResponse>({
|
||||
queryKey: ["organizationTypes"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
// Use a default tenant key if none is found
|
||||
const tenantKey = Cookies.get("tenant-key") || "default-tenant-key";
|
||||
const unitId = Cookies.get("unit-id");
|
||||
|
||||
const response = await getOrganizationTypes();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching organization types:", error);
|
||||
// Return mock data so the UI doesn't break
|
||||
return {
|
||||
count: MOCK_ORGANIZATION_TYPES.length,
|
||||
items: MOCK_ORGANIZATION_TYPES,
|
||||
};
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
// Always enable the query, regardless of tenant key
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
return {
|
||||
// Ensure we always return at least the mock data if nothing is returned
|
||||
organizationTypesResponse: organizationTypesResponse || {
|
||||
count: MOCK_ORGANIZATION_TYPES.length,
|
||||
items: MOCK_ORGANIZATION_TYPES,
|
||||
},
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createOrganization,
|
||||
OrgQueryParams,
|
||||
OrganizationPayload,
|
||||
getOrganizationById,
|
||||
updateOrganization,
|
||||
activateOrganization,
|
||||
deActivateOrganization,
|
||||
getOrganizationsWithAdminFlag,
|
||||
softDeleteOrganization,
|
||||
} from "@/shared/services/organizationsService";
|
||||
import {
|
||||
OrganizationAdminsDto,
|
||||
OrganizationDetailDTO,
|
||||
OrganizationDto,
|
||||
} from "@/shared/dto/organization/organizationDto";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface Unit {
|
||||
id: string;
|
||||
name: string;
|
||||
departments: any[];
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
units: Unit[];
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
export const useOrganizations = (
|
||||
type: "Admin" | "Org",
|
||||
params?: OrgQueryParams,
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
const {
|
||||
data: organizationsResponse,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["organizations", params],
|
||||
queryFn: async () => {
|
||||
const { data } = await getOrganizationsWithAdminFlag(params);
|
||||
|
||||
return {
|
||||
count: data.count as number,
|
||||
items: data.items as OrganizationDto[],
|
||||
};
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
enabled: !!(type === "Org"),
|
||||
});
|
||||
|
||||
const {
|
||||
data: organizationsAdminsResponse,
|
||||
isLoading: isLoadingAdmins,
|
||||
isError: isErrorAdmins,
|
||||
refetch: refetchAdmins,
|
||||
} = useQuery({
|
||||
queryKey: ["organizationAdmins", params?.skip, params?.take, params?.name, params?.orderBy],
|
||||
queryFn: async () => {
|
||||
const { data } = await getOrganizationsWithAdminFlag(params);
|
||||
return {
|
||||
count: data.count as number,
|
||||
items: data.items as OrganizationAdminsDto[],
|
||||
};
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
enabled: !!(type === "Admin"),
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: createMutation,
|
||||
isPending: isOrganizationCreating,
|
||||
isError: organizationCreationError,
|
||||
status: organizationCreationStatus,
|
||||
} = useMutation({
|
||||
mutationFn: async (payload: OrganizationPayload) => {
|
||||
const { data } = await createOrganization(payload);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Organization created successfully!", {
|
||||
description: "Redirecting...",
|
||||
});
|
||||
|
||||
navigate("/user-management/organizations");
|
||||
queryClient.invalidateQueries({ queryKey: ["organizations"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
const {
|
||||
mutate: editMutation,
|
||||
isPending: isOrganizationEditing,
|
||||
isError: organizationEditError,
|
||||
status: organizationEditStatus,
|
||||
} = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
payload,
|
||||
}: {
|
||||
id: string;
|
||||
payload: OrganizationPayload;
|
||||
}) => {
|
||||
const { data } = await updateOrganization(id, payload);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Organization created successfully!", {
|
||||
description: "Redirecting...",
|
||||
});
|
||||
|
||||
navigate("/user-management/organizations");
|
||||
queryClient.invalidateQueries({ queryKey: ["organizations"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
const { mutate: activateMutation, isPending: isOrganizationActivating } =
|
||||
useMutation({
|
||||
mutationFn: async ({ id }: { id: string }) => {
|
||||
const { data } = await activateOrganization(id);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Organization Activated successfully!", {
|
||||
description: "Redirecting...",
|
||||
});
|
||||
|
||||
navigate("/user-management/organizations");
|
||||
queryClient.invalidateQueries({ queryKey: ["organizations"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
const { mutate: deActivateMutation, isPending: isOrganizationDeactivating } =
|
||||
useMutation({
|
||||
mutationFn: async ({ id }: { id: string }) => {
|
||||
const { data } = await deActivateOrganization(id);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Organization De-Activated successfully!", {
|
||||
description: "Redirecting...",
|
||||
});
|
||||
|
||||
navigate("/user-management/organizations");
|
||||
queryClient.invalidateQueries({ queryKey: ["organizations"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
const { mutate: deleteMutation, isPending: isOrganizationDeleting } =
|
||||
useMutation({
|
||||
mutationFn: async ({ id }: { id: string }) => {
|
||||
const { data } = await softDeleteOrganization(id);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Organization Deleted successfully!", {
|
||||
description: "Redirecting...",
|
||||
});
|
||||
|
||||
navigate("/user-management/organizations");
|
||||
queryClient.invalidateQueries({ queryKey: ["organizations"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: getOrganization, isPending: isFetchingOrganization } =
|
||||
useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const { data } = await getOrganizationById(id);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
organizationsResponse,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
createOrganization: createMutation,
|
||||
editOrganization: editMutation,
|
||||
isCreating: isOrganizationCreating,
|
||||
isEditing: isOrganizationEditing,
|
||||
editStatus: organizationEditStatus,
|
||||
organizationCreationError,
|
||||
organizationEditError,
|
||||
getOrganizationByDetails: getOrganization,
|
||||
isFetchingOrganization,
|
||||
activateOrganization: activateMutation,
|
||||
isOrganizationActivating,
|
||||
deactivateOrganization: deActivateMutation,
|
||||
isOrganizationDeactivating,
|
||||
deleteOrganization: deleteMutation,
|
||||
isOrganizationDeleting,
|
||||
organizationsAdminsResponse,
|
||||
isLoadingAdmins,
|
||||
isErrorAdmins,
|
||||
refetchAdmins,
|
||||
};
|
||||
};
|
||||
|
||||
export function useOrganizationDetail(type: string, id: string) {
|
||||
const query = useQuery<{ items: OrganizationDetailDTO }, Error>({
|
||||
queryKey: ["organization-detail", id],
|
||||
queryFn: async () => {
|
||||
const { data } = await getOrganizationById(id);
|
||||
return { items: data };
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
enabled: type === "Org" && !!id,
|
||||
});
|
||||
|
||||
return {
|
||||
organizationsDetailResponse: query.data,
|
||||
isDetailLoading: query.isLoading,
|
||||
isDetailError: query.isError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createPasswordSettings,
|
||||
getPasswordSettings,
|
||||
updatePasswordSettings,
|
||||
deletePasswordSettings,
|
||||
PasswordSettings,
|
||||
} from "../services/api/passwordSettingsService";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
export const usePasswordSettingsQuery = () =>
|
||||
useQuery({
|
||||
queryKey: ["passwordSettings"],
|
||||
queryFn: () => getPasswordSettings(),
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
});
|
||||
|
||||
export const usePasswordSettingsMutations = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (payload: PasswordSettings) => updatePasswordSettings(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["passwordSettings"] });
|
||||
toast.success(
|
||||
t("passwordSettings.updated", "Password settings updated successfully")
|
||||
);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (payload: PasswordSettings) => createPasswordSettings(payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["passwordSettings"] });
|
||||
toast.success(
|
||||
t("passwordSettings.created", "Password settings created successfully")
|
||||
);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
const delete_mutation = useMutation({
|
||||
mutationFn: (id: string) => deletePasswordSettings(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["passwordSettings"] });
|
||||
toast.success(
|
||||
t("passwordSettings.deleted", "Password settings deleted successfully")
|
||||
);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("Delete mutation error:", error);
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
return { update, create, delete: delete_mutation };
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type PositionType = "TeamLeader" | "TeamMember" | "Director" | "Other";
|
||||
|
||||
export interface Position {
|
||||
id: string;
|
||||
title: string;
|
||||
type?: PositionType;
|
||||
employeeCount: number;
|
||||
employees: any[];
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
export const usePositions = (departmentId: string, unitId: string) => {
|
||||
const [positions, setPositions] = useState<Position[]>([]);
|
||||
const [selectedPositionId, setSelectedPositionId] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Fetch positions for a department
|
||||
const fetchPositions = async () => {
|
||||
if (!departmentId) {
|
||||
setPositions([]);
|
||||
setSelectedPositionId("");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// const response = await axios.get<ApiResponse<Position[]>>(`/api/departments/${departmentId}/positions`);
|
||||
// const fetchedPositions = response.data.data;
|
||||
|
||||
// For now, simulate API response with mock data
|
||||
setTimeout(() => {
|
||||
// Mock positions for this department
|
||||
const mockPositions: Position[] = [
|
||||
{
|
||||
id: "pos1",
|
||||
title: "Team Leader",
|
||||
type: "TeamLeader",
|
||||
employeeCount: 1,
|
||||
employees: [
|
||||
{
|
||||
id: "emp1",
|
||||
name: "Abebe Kebede",
|
||||
email: "abebe@example.com",
|
||||
role: "Manager",
|
||||
inviteStatus: "Accepted",
|
||||
},
|
||||
],
|
||||
isExpanded: true,
|
||||
},
|
||||
{
|
||||
id: "pos2",
|
||||
title: "Team Member",
|
||||
type: "TeamMember",
|
||||
employeeCount: 3,
|
||||
employees: [
|
||||
{
|
||||
id: "emp2",
|
||||
name: "Kebede Abebe",
|
||||
email: "kebede@example.com",
|
||||
inviteStatus: "Pending",
|
||||
},
|
||||
{
|
||||
id: "emp3",
|
||||
name: "Chala Demeke",
|
||||
email: "chala@example.com",
|
||||
inviteStatus: "Not Invited",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pos3",
|
||||
title: "Director",
|
||||
type: "Director",
|
||||
employeeCount: 1,
|
||||
employees: [
|
||||
{
|
||||
id: "emp4",
|
||||
name: "Tigist Alemu",
|
||||
email: "tigist@example.com",
|
||||
role: "Director",
|
||||
inviteStatus: "Accepted",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
setPositions(mockPositions);
|
||||
if (mockPositions.length > 0) {
|
||||
setSelectedPositionId(mockPositions[0].id);
|
||||
} else {
|
||||
setSelectedPositionId("");
|
||||
}
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err : new Error("Failed to fetch positions")
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new position
|
||||
const createPosition = async (
|
||||
positionData: Partial<Position>
|
||||
): Promise<Position> => {
|
||||
if (!departmentId) {
|
||||
throw new Error("No department selected");
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// const response = await axios.post<ApiResponse<Position>>(
|
||||
// `/api/departments/${departmentId}/positions`,
|
||||
// positionData
|
||||
// );
|
||||
// const newPosition = response.data.data;
|
||||
|
||||
// For now, simulate API response
|
||||
const newPosition: Position = {
|
||||
id: `pos-${Date.now()}`,
|
||||
title: positionData.title || "New Position",
|
||||
type: positionData.type || "Other",
|
||||
employeeCount: 0,
|
||||
employees: [],
|
||||
isExpanded: false,
|
||||
};
|
||||
|
||||
setPositions((prev) => [...prev, newPosition]);
|
||||
setLoading(false);
|
||||
return newPosition;
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err : new Error("Failed to create position")
|
||||
);
|
||||
setLoading(false);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Update an existing position
|
||||
const updatePosition = async (
|
||||
positionId: string,
|
||||
updates: Partial<Position>
|
||||
): Promise<Position> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// const response = await axios.put<ApiResponse<Position>>(
|
||||
// `/api/positions/${positionId}`,
|
||||
// updates
|
||||
// );
|
||||
// const updatedPosition = response.data.data;
|
||||
|
||||
// For now, simulate API response
|
||||
const updatedPositions = positions.map((position) =>
|
||||
position.id === positionId ? { ...position, ...updates } : position
|
||||
);
|
||||
|
||||
setPositions(updatedPositions);
|
||||
setLoading(false);
|
||||
|
||||
const updatedPosition = updatedPositions.find((p) => p.id === positionId);
|
||||
if (!updatedPosition) {
|
||||
throw new Error("Position not found after update");
|
||||
}
|
||||
|
||||
return updatedPosition;
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err : new Error("Only a super admin can update this position type")
|
||||
);
|
||||
setLoading(false);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a position
|
||||
const deletePosition = async (positionId: string): Promise<void> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// await axios.delete<ApiResponse<void>>(`/api/positions/${positionId}`);
|
||||
|
||||
// For now, simulate API response
|
||||
setPositions((prev) =>
|
||||
prev.filter((position) => position.id !== positionId)
|
||||
);
|
||||
|
||||
// If the deleted position was selected, select another position
|
||||
if (selectedPositionId === positionId) {
|
||||
const remaining = positions.filter(
|
||||
(position) => position.id !== positionId
|
||||
);
|
||||
if (remaining.length > 0) {
|
||||
setSelectedPositionId(remaining[0].id);
|
||||
} else {
|
||||
setSelectedPositionId("");
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err : new Error("Failed to delete position")
|
||||
);
|
||||
setLoading(false);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle expand/collapse for a position
|
||||
const togglePositionExpand = (positionId: string) => {
|
||||
setPositions((prev) =>
|
||||
prev.map((position) =>
|
||||
position.id === positionId
|
||||
? { ...position, isExpanded: !position.isExpanded }
|
||||
: position
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Select a position
|
||||
const selectPosition = (positionId: string) => {
|
||||
setSelectedPositionId(positionId);
|
||||
};
|
||||
|
||||
// Filter positions by type
|
||||
const getPositionsByType = (type: PositionType) => {
|
||||
return positions.filter((position) => position.type === type);
|
||||
};
|
||||
|
||||
// Get team leaders
|
||||
const getTeamLeaders = () => {
|
||||
return positions.filter((position) => position.type === "TeamLeader");
|
||||
};
|
||||
|
||||
// Get team members
|
||||
const getTeamMembers = () => {
|
||||
return positions.filter((position) => position.type === "TeamMember");
|
||||
};
|
||||
|
||||
// Get directors
|
||||
const getDirectors = () => {
|
||||
return positions.filter((position) => position.type === "Director");
|
||||
};
|
||||
|
||||
// Load positions when departmentId changes
|
||||
useEffect(() => {
|
||||
fetchPositions();
|
||||
}, [departmentId, unitId]);
|
||||
|
||||
return {
|
||||
positions,
|
||||
selectedPositionId,
|
||||
selectedPosition: positions.find(
|
||||
(position) => position.id === selectedPositionId
|
||||
),
|
||||
loading,
|
||||
error,
|
||||
fetchPositions,
|
||||
createPosition,
|
||||
updatePosition,
|
||||
deletePosition,
|
||||
togglePositionExpand,
|
||||
selectPosition,
|
||||
getPositionsByType,
|
||||
getTeamLeaders,
|
||||
getTeamMembers,
|
||||
getDirectors,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { prefixSuffixService } from "@/user-management/services/api/prefixSuffixService";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export interface RemarkParams{
|
||||
skip:number,
|
||||
take:number,
|
||||
orderBy?:string;
|
||||
}
|
||||
|
||||
|
||||
export const useRemarkByUnitId = (unitId: string, params:RemarkParams) => {
|
||||
return useQuery({
|
||||
queryKey: ["remark", unitId,params],
|
||||
queryFn: async () => {
|
||||
const response = await prefixSuffixService.getRemarkList(unitId,params);
|
||||
return response.data ;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getSites,
|
||||
getSiteById,
|
||||
createSite,
|
||||
updateSite,
|
||||
softDeleteSite,
|
||||
deleteSite,
|
||||
restoreSite,
|
||||
} from "@/super-admin/services/api/sitesService";
|
||||
import { SiteDto, SitePayloadDto, SiteQueryParams } from "@/super-admin/dto/SitesDto";
|
||||
import { toast } from "sonner";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Helper function to transform English name into snake_case format
|
||||
export function toSnakeCase(str: string): string {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-_]/g, "") // remove special characters
|
||||
.replace(/[\s-]+/g, "_"); // replace spaces or hyphens with underscores
|
||||
}
|
||||
|
||||
export const useSites = (params?: SiteQueryParams) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
// 🔵 Query: Fetch sites list
|
||||
const {
|
||||
data: sitesResponse,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["sites", params],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSites(params);
|
||||
|
||||
// Defensively support both [{...}] and { items: [...], count: X } formats
|
||||
const items = Array.isArray(data) ? data : (data?.items || []);
|
||||
const count = Array.isArray(data) ? data.length : (data?.count || items.length || 0);
|
||||
|
||||
return {
|
||||
items: items as SiteDto[],
|
||||
count: count as number,
|
||||
};
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// 🟢 Mutation: Create Site
|
||||
const { mutate: createMutation, isPending: isCreating } = useMutation({
|
||||
mutationFn: async (payload: SitePayloadDto) => {
|
||||
const transformedPayload: SitePayloadDto = {
|
||||
...payload,
|
||||
name: {
|
||||
...payload.name,
|
||||
en: toSnakeCase(payload.name.en),
|
||||
},
|
||||
};
|
||||
const { data } = await createSite(transformedPayload);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Site created successfully!");
|
||||
queryClient.invalidateQueries({ queryKey: ["sites"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
// 🟠 Mutation: Update Site
|
||||
const { mutate: updateMutation, isPending: isUpdating } = useMutation({
|
||||
mutationFn: async ({ id, payload }: { id: string; payload: SitePayloadDto }) => {
|
||||
const transformedPayload: SitePayloadDto = {
|
||||
...payload,
|
||||
name: {
|
||||
...payload.name,
|
||||
en: toSnakeCase(payload.name.en),
|
||||
},
|
||||
};
|
||||
const { data } = await updateSite(id, transformedPayload);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Site updated successfully!");
|
||||
queryClient.invalidateQueries({ queryKey: ["sites"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
// 🟡 Mutation: Archive Site (Soft Delete)
|
||||
const { mutate: archiveMutation, isPending: isArchiving } = useMutation({
|
||||
mutationFn: async ({ id }: { id: string }) => {
|
||||
const { data } = await softDeleteSite(id);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Site archived successfully!");
|
||||
queryClient.invalidateQueries({ queryKey: ["sites"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
// 🔴 Mutation: Permanent Delete Site
|
||||
const { mutate: deleteMutation, isPending: isDeleting } = useMutation({
|
||||
mutationFn: async ({ id }: { id: string }) => {
|
||||
const { data } = await deleteSite(id);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Site permanently deleted!");
|
||||
queryClient.invalidateQueries({ queryKey: ["sites"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
// 🟣 Mutation: Restore Archived Site
|
||||
const { mutate: restoreMutation, isPending: isRestoring } = useMutation({
|
||||
mutationFn: async ({ id }: { id: string }) => {
|
||||
const { data } = await restoreSite(id);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Site restored successfully!");
|
||||
queryClient.invalidateQueries({ queryKey: ["sites"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sitesResponse,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
createSite: createMutation,
|
||||
isCreating,
|
||||
updateSite: updateMutation,
|
||||
isUpdating,
|
||||
archiveSite: archiveMutation,
|
||||
isArchiving,
|
||||
deleteSite: deleteMutation,
|
||||
isDeleting,
|
||||
restoreSite: restoreMutation,
|
||||
isRestoring,
|
||||
};
|
||||
};
|
||||
|
||||
export const useSiteDetail = (id: string, enabled: boolean = true) => {
|
||||
const {
|
||||
data: site,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["site", id],
|
||||
queryFn: async () => {
|
||||
const { data } = await getSiteById(id);
|
||||
return data;
|
||||
},
|
||||
enabled: enabled && !!id,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return {
|
||||
site,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getUnitInfo, getUnitInfoByUnitID } from "../services/api/userService";
|
||||
|
||||
export const useUnitInfo = () => {
|
||||
return useQuery({
|
||||
queryKey: ["unitInfo"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUnitInfo();
|
||||
return data ;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const useUnitInfoByUnitID = (unitID:string) => {
|
||||
return useQuery({
|
||||
queryKey: ["unitInfobyID"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUnitInfoByUnitID(unitID);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,230 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
id: string;
|
||||
name: string;
|
||||
positions: any[];
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
export interface Unit {
|
||||
id: string;
|
||||
name: string;
|
||||
departments: Department[];
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
export const useUnits = (organizationId: string) => {
|
||||
const [units, setUnits] = useState<Unit[]>([]);
|
||||
const [selectedUnitId, setSelectedUnitId] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Fetch units for an organization
|
||||
const fetchUnits = async () => {
|
||||
if (!organizationId) {
|
||||
setUnits([]);
|
||||
setSelectedUnitId("");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
// Create a new unit
|
||||
const createUnit = async (unitData: Partial<Unit>): Promise<Unit> => {
|
||||
if (!organizationId) {
|
||||
throw new Error("No organization selected");
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// const response = await axios.post<ApiResponse<Unit>>(
|
||||
// `/api/organizations/${organizationId}/units`,
|
||||
// unitData
|
||||
// );
|
||||
// const newUnit = response.data.data;
|
||||
|
||||
// For now, simulate API response
|
||||
const newUnit: Unit = {
|
||||
id: `unit-${Date.now()}`,
|
||||
name: unitData.name || "New Unit",
|
||||
departments: [],
|
||||
isExpanded: false,
|
||||
};
|
||||
|
||||
setUnits((prev) => [...prev, newUnit]);
|
||||
setLoading(false);
|
||||
return newUnit;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error("Failed to create unit"));
|
||||
setLoading(false);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Update an existing unit
|
||||
const updateUnit = async (
|
||||
unitId: string,
|
||||
updates: Partial<Unit>
|
||||
): Promise<Unit> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// const response = await axios.put<ApiResponse<Unit>>(
|
||||
// `/api/units/${unitId}`,
|
||||
// updates
|
||||
// );
|
||||
// const updatedUnit = response.data.data;
|
||||
|
||||
// For now, simulate API response
|
||||
const updatedUnits = units.map((unit) =>
|
||||
unit.id === unitId ? { ...unit, ...updates } : unit
|
||||
);
|
||||
|
||||
setUnits(updatedUnits);
|
||||
setLoading(false);
|
||||
|
||||
const updatedUnit = updatedUnits.find((u) => u.id === unitId);
|
||||
if (!updatedUnit) {
|
||||
throw new Error("Unit not found after update");
|
||||
}
|
||||
|
||||
return updatedUnit;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error("Failed to update unit"));
|
||||
setLoading(false);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a unit
|
||||
const deleteUnit = async (unitId: string): Promise<void> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// await axios.delete<ApiResponse<void>>(`/api/units/${unitId}`);
|
||||
|
||||
// For now, simulate API response
|
||||
setUnits((prev) => prev.filter((unit) => unit.id !== unitId));
|
||||
|
||||
// If the deleted unit was selected, select another unit
|
||||
if (selectedUnitId === unitId) {
|
||||
const remaining = units.filter((unit) => unit.id !== unitId);
|
||||
if (remaining.length > 0) {
|
||||
setSelectedUnitId(remaining[0].id);
|
||||
} else {
|
||||
setSelectedUnitId("");
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error("Failed to delete unit"));
|
||||
setLoading(false);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Add department to a unit
|
||||
const addDepartment = async (
|
||||
unitId: string,
|
||||
departmentName: string
|
||||
): Promise<Department> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// In a real app, this would be an API call
|
||||
// const response = await axios.post<ApiResponse<Department>>(
|
||||
// `/api/units/${unitId}/departments`,
|
||||
// { name: departmentName }
|
||||
// );
|
||||
// const newDepartment = response.data.data;
|
||||
|
||||
// For now, simulate API response
|
||||
const newDepartment: Department = {
|
||||
id: `dept-${Date.now()}`,
|
||||
name: departmentName,
|
||||
positions: [],
|
||||
isExpanded: false,
|
||||
};
|
||||
|
||||
setUnits((prev) =>
|
||||
prev.map((unit) =>
|
||||
unit.id === unitId
|
||||
? { ...unit, departments: [...unit.departments, newDepartment] }
|
||||
: unit
|
||||
)
|
||||
);
|
||||
|
||||
setLoading(false);
|
||||
return newDepartment;
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err : new Error("Failed to add department")
|
||||
);
|
||||
setLoading(false);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle expand/collapse for a unit
|
||||
const toggleUnitExpand = (unitId: string) => {
|
||||
setUnits((prev) =>
|
||||
prev.map((unit) =>
|
||||
unit.id === unitId ? { ...unit, isExpanded: !unit.isExpanded } : unit
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Toggle expand/collapse for a department
|
||||
const toggleDepartmentExpand = (unitId: string, deptId: string) => {
|
||||
setUnits((prev) =>
|
||||
prev.map((unit) => {
|
||||
if (unit.id !== unitId) return unit;
|
||||
|
||||
return {
|
||||
...unit,
|
||||
departments: unit.departments.map((dept) =>
|
||||
dept.id === deptId
|
||||
? { ...dept, isExpanded: !dept.isExpanded }
|
||||
: dept
|
||||
),
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Select a unit
|
||||
const selectUnit = (unitId: string) => {
|
||||
setSelectedUnitId(unitId);
|
||||
};
|
||||
|
||||
// Load units when organizationId changes
|
||||
useEffect(() => {
|
||||
fetchUnits();
|
||||
}, [organizationId]);
|
||||
|
||||
return {
|
||||
units,
|
||||
selectedUnitId,
|
||||
selectedUnit: units.find((unit) => unit.id === selectedUnitId),
|
||||
loading,
|
||||
error,
|
||||
fetchUnits,
|
||||
createUnit,
|
||||
updateUnit,
|
||||
deleteUnit,
|
||||
addDepartment,
|
||||
toggleUnitExpand,
|
||||
toggleDepartmentExpand,
|
||||
selectUnit,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import {
|
||||
removeUnitAdminRole,
|
||||
assignFirstsForSecond,
|
||||
assignSecondsForFirst,
|
||||
deleteUserRole,
|
||||
removeFirstsForSecond,
|
||||
removeSecondsForFirst,
|
||||
RemoveOrAssignUnitAdminPayload,
|
||||
removeOrgAdminRole,
|
||||
RemoveOrAssignOrgAdminPayload,
|
||||
} from "@/super-admin/services/api/userRoleService";
|
||||
|
||||
export const useUserRoles = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
// Remove Unit Admin Role
|
||||
const { mutateAsync: removeAdminRole, isPending: isRemoving } = useMutation({
|
||||
mutationFn: (payload: RemoveOrAssignUnitAdminPayload) =>
|
||||
removeUnitAdminRole(payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Unit admin role removed successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
const { mutateAsync: removeUnitAdmin, isPending: isRemovingUnitAdmin } =
|
||||
useMutation({
|
||||
mutationFn: (payload: RemoveOrAssignOrgAdminPayload) =>
|
||||
removeOrgAdminRole(payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Unit admin role removed successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
// Assign Firsts for Second
|
||||
const { mutate: assignFirsts, isPending: isAssigningFirsts } = useMutation({
|
||||
mutationFn: (payload: { secondId: string; firstIds: string[] }) =>
|
||||
assignFirstsForSecond(payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Firsts assigned successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
// Assign Seconds for First
|
||||
const { mutate: assignSeconds, isPending: isAssigningSeconds } = useMutation({
|
||||
mutationFn: (payload: { firstId: string; secondIds: string[] }) =>
|
||||
assignSecondsForFirst(payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Seconds assigned successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
// Remove Firsts for Second
|
||||
const { mutate: removeFirsts, isPending: isRemovingFirsts } = useMutation({
|
||||
mutationFn: (payload: { secondId: string; firstIds: string[] }) =>
|
||||
removeFirstsForSecond(payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Firsts removed successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
// Remove Seconds for First
|
||||
const { mutate: removeSeconds, isPending: isRemovingSeconds } = useMutation({
|
||||
mutationFn: (payload: { firstId: string; secondIds: string[] }) =>
|
||||
removeSecondsForFirst(payload),
|
||||
onSuccess: () => {
|
||||
toast.success("Seconds removed successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
// Delete User Role by ID
|
||||
const { mutate: deleteRole, isPending: isDeleting } = useMutation({
|
||||
mutationFn: (id: string) => deleteUserRole(id),
|
||||
onSuccess: () => {
|
||||
toast.success("Role deleted successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
removeAdminRole,
|
||||
removeUnitAdmin,
|
||||
assignFirsts,
|
||||
assignSeconds,
|
||||
removeFirsts,
|
||||
removeSeconds,
|
||||
deleteRole,
|
||||
isRemoving,
|
||||
isRemovingUnitAdmin,
|
||||
isAssigningFirsts,
|
||||
isAssigningSeconds,
|
||||
isRemovingFirsts,
|
||||
isRemovingSeconds,
|
||||
isDeleting,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { OrganizationUserDto, User } from "@/shared/dto/user/usersDto";
|
||||
import { getEmployeesUnderOrg } from "@/shared/services/organizationsService";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getUserById } from "../services/api/userService";
|
||||
|
||||
export const useUsers = (organizationId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getEmployeesUnderOrg(organizationId);
|
||||
return data as { items: OrganizationUserDto[]; count: number };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const useUsersByID = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["user",id],
|
||||
queryFn: async () => {
|
||||
const response = await getUserById(id);
|
||||
return response.data as User;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { formatDistanceToNow, parseISO } from "date-fns";
|
||||
|
||||
export const getRelativeTime = (isoDate: string): string => {
|
||||
return formatDistanceToNow(parseISO(isoDate), { addSuffix: true });
|
||||
};
|
||||
28
apps/edr-freight-web/backoffice/src/super-admin/lib/utils.ts
Normal file
28
apps/edr-freight-web/backoffice/src/super-admin/lib/utils.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatDate(input: string | number | Date): string {
|
||||
const date = new Date(input);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateTime(input: string | number | Date): string {
|
||||
const date = new Date(input);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// Add any other utility functions here that might be needed
|
||||
@@ -0,0 +1,90 @@
|
||||
import axios from "@/shared/services/axiosInstance";
|
||||
import { DashboardStatsDto } from "@/super-admin/components/dashboard/types/dashboardStatsDto";
|
||||
import axiosInstance from "../../../shared/services/axiosInstance";
|
||||
import { AxiosResponse, AxiosError } from "axios";
|
||||
|
||||
// This is your DTO structure
|
||||
export const getDashboardStats = async (): Promise<DashboardStatsDto> => {
|
||||
const { data } = await axios.get("/dashboard/stats");
|
||||
return data;
|
||||
};
|
||||
|
||||
// Types for organization report data
|
||||
export interface OrganizationReport {
|
||||
unitsCount: number;
|
||||
employeesCount: number;
|
||||
positionsCount: number;
|
||||
totalDocuments?: number;
|
||||
recentActivities?: {
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export type UnitReport = Omit<OrganizationReport, "unitsCount"> & {
|
||||
unitsCount?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches organization report data for the dashboard
|
||||
* @param organizationId - The ID of the organization
|
||||
* @returns Promise with the organization report data
|
||||
*/
|
||||
export const getOrganizationReport = async (
|
||||
organizationId: string
|
||||
): Promise<AxiosResponse<OrganizationReport>> => {
|
||||
try {
|
||||
return await axiosInstance.get(`/organizations/${organizationId}/report`);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AxiosError && error.response) {
|
||||
// Handle specific error cases if needed
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getUnitReport = async (
|
||||
organizationId: string,
|
||||
unitId: string,
|
||||
): Promise<AxiosResponse<UnitReport>> => {
|
||||
return axiosInstance.get(
|
||||
`/organizations/${organizationId}/report/${unitId}/unit`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches total employees count for an organization
|
||||
* @param organizationId - The ID of the organization
|
||||
* @returns Promise with the total employees count
|
||||
*/
|
||||
export const getOrganizationEmployees = async (
|
||||
organizationId: string
|
||||
): Promise<AxiosResponse<{ total: number }>> => {
|
||||
return axiosInstance.get(`/organizations/${organizationId}/employees/count`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches total departments count for an organization
|
||||
* @param organizationId - The ID of the organization
|
||||
* @returns Promise with the total departments count
|
||||
*/
|
||||
export const getOrganizationDepartments = async (
|
||||
organizationId: string
|
||||
): Promise<AxiosResponse<{ total: number }>> => {
|
||||
return axiosInstance.get(
|
||||
`/organizations/${organizationId}/departments/count`
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches total units count for an organization
|
||||
* @param organizationId - The ID of the organization
|
||||
* @returns Promise with the total units count
|
||||
*/
|
||||
export const getOrganizationUnits = async (
|
||||
organizationId: string
|
||||
): Promise<AxiosResponse<{ total: number }>> => {
|
||||
return axiosInstance.get(`/organizations/${organizationId}/units/count`);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { externalParam } from "@/external-portal/services/portalOutgoingService";
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import recordAxiosInstance from "@/shared/services/recordAxiosInstance";
|
||||
import { AdoptTemplateTypes } from "@/super-admin/components/templates/template";
|
||||
import { CreateTemplateTypes } from "@/super-admin/components/templates/types/templateTypes";
|
||||
|
||||
export const getDelegationTemplates = async () => {
|
||||
const { data } = await recordAxiosInstance.get("/record-templates/global", {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getDelegationTemplateById = async (
|
||||
id: string,
|
||||
params: externalParam,
|
||||
) => {
|
||||
const { data } = await recordAxiosInstance.get(`/record-templates/${id}`, {
|
||||
headers: withHeaders(),
|
||||
...params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createDelegationTemplate = async (
|
||||
template: CreateTemplateTypes,
|
||||
) => {
|
||||
const { data } = await recordAxiosInstance.post(
|
||||
`/record-templates/global/delegation`,
|
||||
template,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
},
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateDelegationTemplate = async (
|
||||
id: string,
|
||||
template: CreateTemplateTypes,
|
||||
) => {
|
||||
const { data } = await recordAxiosInstance.patch(
|
||||
`/record-templates/global/${id}`,
|
||||
template,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
},
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteDelegationTemplate = async (id: string) => {
|
||||
const { data } = await recordAxiosInstance.delete(`/record-templates/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const adoptDelegationTemplate = async (item: AdoptTemplateTypes) => {
|
||||
const { data } = await recordAxiosInstance.post(
|
||||
`/record-templates/adopt-to-unit`,
|
||||
item,
|
||||
{
|
||||
headers: withHeaders(),
|
||||
},
|
||||
);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import axiosInstance from "../../../shared/services/axiosInstance";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export interface OrgHeaders {
|
||||
tenantKey: string;
|
||||
unitId?: string;
|
||||
}
|
||||
|
||||
export interface OrganizationAdminPayload {
|
||||
organizationId: string;
|
||||
username: string;
|
||||
email: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
export interface UnitAdminPayload {
|
||||
unitId: string;
|
||||
username: string;
|
||||
email: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
phoneNumber: string;
|
||||
}
|
||||
|
||||
export interface OrganizationAdmin {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
username: string;
|
||||
email: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface OrgAdminQueryParams {
|
||||
orderBy?: string;
|
||||
take?: number;
|
||||
skip?: number;
|
||||
order?: string;
|
||||
}
|
||||
|
||||
export const getOrganizationAdmins = async (
|
||||
params?: OrgAdminQueryParams
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get("/organizations/org-admins", {
|
||||
...{ headers: withHeaders() },
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
export const getOrganizationAdminById = async (
|
||||
id: string,
|
||||
params?: OrgAdminQueryParams
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organizations/org-admins/${id}`, {
|
||||
...{ headers: withHeaders() },
|
||||
params,
|
||||
});
|
||||
};
|
||||
export const fetchUnitAdminById = async (
|
||||
id: string,
|
||||
params?: OrgAdminQueryParams
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/units/unit-admins/${id}`, {
|
||||
...{ headers: withHeaders() },
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchAllUnitAdminById = async (
|
||||
id: string,
|
||||
params?: OrgAdminQueryParams
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.get(`/organizations/all-admins/${id}`, {
|
||||
...{ headers: withHeaders() },
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
export const assignOrganizationAdmin = async (
|
||||
data: OrganizationAdminPayload
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.post("/organizations/org-admin", data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const assignUnitAdmin = async (
|
||||
data: UnitAdminPayload
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.post("/units/unit-admin", data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateOrganizationAdmin = async (
|
||||
id: string | number,
|
||||
data: OrganizationAdminPayload
|
||||
): Promise<AxiosResponse> => {
|
||||
return axiosInstance.put(`/organizations/org-admin/${id}`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import axiosInstance from "../../../shared/services/axiosInstance";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export interface OrgHeaders {
|
||||
tenantKey: string;
|
||||
unitId?: string;
|
||||
}
|
||||
|
||||
export interface OrgType {
|
||||
id: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
key: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface OrgTypeResponse {
|
||||
count: number;
|
||||
items: OrgType[];
|
||||
}
|
||||
|
||||
export const getOrganizationTypes = async (): Promise<
|
||||
AxiosResponse<OrgTypeResponse>
|
||||
> => {
|
||||
return axiosInstance.get("/organization-types", { headers: withHeaders() });
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import axiosInstance from "@/shared/services/axiosInstance";
|
||||
|
||||
export interface PasswordSettings {
|
||||
id?: string;
|
||||
minimumPasswordLength: number;
|
||||
maximumPasswordLength: number;
|
||||
passwordExpiry: number;
|
||||
sessionTimeout: number;
|
||||
isDefaultPasswordEnabled: boolean;
|
||||
defaultPassword?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system password settings
|
||||
*/
|
||||
export const getPasswordSettings = async (): Promise<PasswordSettings | null> => {
|
||||
const { data } = await axiosInstance.get("/system-configurations");
|
||||
console.log("Password settings API response:", data);
|
||||
|
||||
// API returns an array with {count, items}
|
||||
if (data && data.items && Array.isArray(data.items)) {
|
||||
console.log("Found items array, returning first item:", data.items[0]);
|
||||
// Return the first (latest) item if it exists
|
||||
return data.items[0] || null;
|
||||
}
|
||||
|
||||
// Fallback for direct object response
|
||||
console.log("No items array, returning data as is:", data);
|
||||
return data || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update system password settings
|
||||
*/
|
||||
export const updatePasswordSettings = async (
|
||||
payload: PasswordSettings
|
||||
): Promise<PasswordSettings> => {
|
||||
const { data } = await axiosInstance.put(
|
||||
`/system-configurations/${payload.id}`,
|
||||
payload
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create system password settings
|
||||
*/
|
||||
export const createPasswordSettings = async (
|
||||
payload: PasswordSettings
|
||||
): Promise<PasswordSettings> => {
|
||||
const { data } = await axiosInstance.post(
|
||||
"/system-configurations",
|
||||
payload
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete system password settings
|
||||
*/
|
||||
export const deletePasswordSettings = async (
|
||||
id: string
|
||||
): Promise<void> => {
|
||||
console.log("Calling DELETE /system-configurations/" + id);
|
||||
try {
|
||||
const response = await axiosInstance.delete(`/system-configurations/${id}`);
|
||||
console.log("Delete response:", response);
|
||||
} catch (error) {
|
||||
console.error("Delete API error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import axiosInstance from "@/shared/services/axiosInstance";
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { SiteDto, SitePayloadDto, SiteQueryParams } from "@/super-admin/dto/SitesDto";
|
||||
|
||||
// Fetch all sites (supports pagination/filter params)
|
||||
export const getSites = async (
|
||||
params?: SiteQueryParams
|
||||
): Promise<AxiosResponse<any>> => {
|
||||
return axiosInstance.get("/sites", {
|
||||
headers: withHeaders(),
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
// Fetch site by ID
|
||||
export const getSiteById = async (
|
||||
id: string
|
||||
): Promise<AxiosResponse<SiteDto>> => {
|
||||
return axiosInstance.get(`/sites/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// Create a new site
|
||||
export const createSite = async (
|
||||
data: SitePayloadDto
|
||||
): Promise<AxiosResponse<SiteDto>> => {
|
||||
return axiosInstance.post("/sites", data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// Update an existing site
|
||||
export const updateSite = async (
|
||||
id: string,
|
||||
data: SitePayloadDto
|
||||
): Promise<AxiosResponse<SiteDto>> => {
|
||||
return axiosInstance.put(`/sites/${id}`, data, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// Soft delete (archive) a site
|
||||
export const softDeleteSite = async (
|
||||
id: string
|
||||
): Promise<AxiosResponse<void>> => {
|
||||
return axiosInstance.delete(`/sites/${id}/soft`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// Permanently delete a site
|
||||
export const deleteSite = async (
|
||||
id: string
|
||||
): Promise<AxiosResponse<void>> => {
|
||||
return axiosInstance.delete(`/sites/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
|
||||
// Restore an archived site
|
||||
export const restoreSite = async (
|
||||
id: string
|
||||
): Promise<AxiosResponse<SiteDto>> => {
|
||||
return axiosInstance.patch(`/sites/${id}/restore`, null, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import axiosInstance from "@/shared/services/axiosInstance";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
export interface UserRoleHeaders {
|
||||
delegatorPositionId: string;
|
||||
currentProjectId?: string;
|
||||
}
|
||||
|
||||
|
||||
export interface RemoveOrAssignUnitAdminPayload {
|
||||
unitId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface RemoveOrAssignOrgAdminPayload {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
// Assign Org Admin Role
|
||||
export const assignOrgAdminRole = async (
|
||||
payload: RemoveOrAssignOrgAdminPayload
|
||||
): Promise<AxiosResponse> =>
|
||||
axiosInstance.post("/user-roles/assign-org-admin-role", payload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
// Remove Org Admin Role
|
||||
export const removeOrgAdminRole = async (
|
||||
payload: RemoveOrAssignOrgAdminPayload
|
||||
): Promise<AxiosResponse> =>
|
||||
axiosInstance.post("/user-roles/remove-org-admin-role", payload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
//Assign unit Admin Role
|
||||
export const assignUnitAdminRole = async (
|
||||
payload: RemoveOrAssignUnitAdminPayload
|
||||
): Promise<AxiosResponse> =>
|
||||
axiosInstance.post("/user-roles/assign-unit-admin-role", payload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
// Remove Unit Admin Role
|
||||
export const removeUnitAdminRole = async (
|
||||
payload: RemoveOrAssignUnitAdminPayload
|
||||
): Promise<AxiosResponse> =>
|
||||
axiosInstance.post("/user-roles/remove-unit-admin-role", payload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
// Get roles given first (e.g., user has been assigned roles)
|
||||
export const getRolesGivenFirst = async (id: string): Promise<AxiosResponse> =>
|
||||
axiosInstance.get(`/user-roles/given-first/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
// Get roles given second (e.g., entities assigned to a user)
|
||||
export const getRolesGivenSecond = async (id: string): Promise<AxiosResponse> =>
|
||||
axiosInstance.get(`/user-roles/given-second/${id}`, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
// Assign firsts for second
|
||||
export const assignFirstsForSecond = async (payload: {
|
||||
secondId: string;
|
||||
firstIds: string[];
|
||||
}): Promise<AxiosResponse> =>
|
||||
axiosInstance.post(`/user-roles/assign-firsts-for-second`, payload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
// Assign seconds for first
|
||||
export const assignSecondsForFirst = async (payload: {
|
||||
firstId: string;
|
||||
secondIds: string[];
|
||||
}): Promise<AxiosResponse> =>
|
||||
axiosInstance.post(`/user-roles/assign-seconds-for-first`, payload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
// Delete user role by id
|
||||
export const deleteUserRole = async (id: string): Promise<AxiosResponse> =>
|
||||
axiosInstance.delete(`/user-roles/${id}`, { headers: withHeaders() });
|
||||
|
||||
// Remove firsts for second
|
||||
export const removeFirstsForSecond = async (payload: {
|
||||
secondId: string;
|
||||
firstIds: string[];
|
||||
}): Promise<AxiosResponse> =>
|
||||
axiosInstance.post(`/user-roles/remove-firsts-for-second`, payload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
// Remove seconds for first
|
||||
export const removeSecondsForFirst = async (payload: {
|
||||
firstId: string;
|
||||
secondIds: string[];
|
||||
}): Promise<AxiosResponse> =>
|
||||
axiosInstance.post(`/user-roles/remove-seconds-for-first`, payload, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { withHeaders } from "@/record-management/services/api/withHeaders";
|
||||
import axiosInstance from "@/shared/services/axiosInstance";
|
||||
import recordAxiosInstance from "@/shared/services/recordAxiosInstance";
|
||||
import {
|
||||
ExternalUsersResponseDto,
|
||||
UserTypeDto,
|
||||
} from "@/super-admin/dto/ExternalUsersDto";
|
||||
import { MainDtoResponse } from "@/super-admin/dto/SuperAdminDto";
|
||||
import { ExternalQueryParams } from "@/super-admin/hooks/useExternalUsers";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
// 🔵 Get pending users
|
||||
export const getPendingUsers = async (
|
||||
params?: ExternalQueryParams
|
||||
): Promise<AxiosResponse<ExternalUsersResponseDto>> =>
|
||||
axiosInstance.get("/users/pending", { headers: withHeaders(), params });
|
||||
|
||||
// Dashboard Information
|
||||
export const getUnitInfo = async (
|
||||
params?: ExternalQueryParams
|
||||
): Promise<AxiosResponse<UserTypeDto>> =>
|
||||
recordAxiosInstance.get("/record-boxes/my-units-dashboard", {
|
||||
headers: withHeaders(),
|
||||
params,
|
||||
});
|
||||
|
||||
// unit infromation from super admin side
|
||||
export const getUnitInfoByUnitID = async (
|
||||
UnitId: string,
|
||||
params?: ExternalQueryParams
|
||||
): Promise<AxiosResponse<UserTypeDto>> =>
|
||||
recordAxiosInstance.get(`/record-boxes/summary/${UnitId}`, {
|
||||
headers: withHeaders(),
|
||||
params,
|
||||
});
|
||||
|
||||
// 🔵 Get all users (both external_organization and employee)
|
||||
export const getAllExternalUsers = async (
|
||||
params?: ExternalQueryParams
|
||||
): Promise<AxiosResponse<ExternalUsersResponseDto>> => {
|
||||
const queryParams = {
|
||||
...params,
|
||||
// No userType filter - fetch both external_organization and employee users
|
||||
};
|
||||
return axiosInstance.get("/users/filter", {
|
||||
headers: withHeaders(),
|
||||
params: queryParams,
|
||||
});
|
||||
};
|
||||
export const getExternalUsersNeesApproval = async (
|
||||
params?: ExternalQueryParams
|
||||
): Promise<AxiosResponse<ExternalUsersResponseDto>> => {
|
||||
const queryParams = {
|
||||
...params,
|
||||
// No userType filter - fetch both external_organization and employee users
|
||||
};
|
||||
return axiosInstance.get("/users/filter", {
|
||||
headers: withHeaders(),
|
||||
params: queryParams,
|
||||
});
|
||||
};
|
||||
|
||||
// 🔴 Delete user
|
||||
export const deleteUser = async (id: string): Promise<AxiosResponse<void>> =>
|
||||
axiosInstance.delete(`/users/${id}`, { headers: withHeaders() });
|
||||
|
||||
export const getUserById = async (id: string): Promise<AxiosResponse<any>> =>
|
||||
axiosInstance.get(`/users/${id}`, { headers: withHeaders() });
|
||||
|
||||
// 🟢 Activate user
|
||||
export const activateUser = async (id: string): Promise<AxiosResponse<void>> =>
|
||||
axiosInstance.patch(`/users/activate-user/${id}`, null, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
export const approveUser = async (
|
||||
id: string,
|
||||
status: string
|
||||
): Promise<AxiosResponse<void>> =>
|
||||
axiosInstance.patch(
|
||||
`/users/approve/${id}`,
|
||||
{ status },
|
||||
{
|
||||
headers: withHeaders(),
|
||||
}
|
||||
);
|
||||
|
||||
// 🟠 Deactivate user
|
||||
export const deactivateUser = async (
|
||||
id: string
|
||||
): Promise<AxiosResponse<void>> =>
|
||||
axiosInstance.patch(`/users/deactivate-user/${id}`, null, {
|
||||
headers: withHeaders(),
|
||||
});
|
||||
|
||||
export const getAllRecords = async (
|
||||
UnitId: string,
|
||||
params?: ExternalQueryParams
|
||||
): Promise<AxiosResponse<MainDtoResponse>> =>
|
||||
recordAxiosInstance.get(`/record-boxes/unit-box/${UnitId}`, {
|
||||
headers: withHeaders(),
|
||||
params,
|
||||
});
|
||||
Reference in New Issue
Block a user