mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
fix ui
This commit is contained in:
@@ -1,44 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,72 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,53 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,26 +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;
|
||||
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;
|
||||
|
||||
@@ -1,34 +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,
|
||||
};
|
||||
};
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export interface DashboardStatsDto {
|
||||
totalOrgs: number;
|
||||
pendingRequests: number;
|
||||
orgAdmins: number;
|
||||
percentChanges: {
|
||||
orgs: number;
|
||||
pending: number;
|
||||
admins: number;
|
||||
};
|
||||
}
|
||||
export interface DashboardStatsDto {
|
||||
totalOrgs: number;
|
||||
pendingRequests: number;
|
||||
orgAdmins: number;
|
||||
percentChanges: {
|
||||
orgs: number;
|
||||
pending: number;
|
||||
admins: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,129 +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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,142 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,137 +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;
|
||||
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;
|
||||
|
||||
@@ -1,332 +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;
|
||||
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;
|
||||
|
||||
@@ -1,124 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,378 +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;
|
||||
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;
|
||||
|
||||
@@ -1,481 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,277 +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>
|
||||
);
|
||||
}
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { ScrollArea } from "@/shared/common/ui/scroll-area";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { cn } from "@/super-admin/lib/utils";
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
import {
|
||||
assignOrgAdminRole,
|
||||
RemoveOrAssignOrgAdminPayload,
|
||||
} from "@/super-admin/services/api/userRoleService";
|
||||
import { useEmployees } from "@/user-management/hooks/useEmployees";
|
||||
|
||||
interface AssignOrgAdminDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function AssignOrgAdminDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: AssignOrgAdminDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
const [selectedOrg, setSelectedOrg] = useState("");
|
||||
const [selectedUser, setSelectedUser] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [isAssigning, setIsAssigning] = useState(false);
|
||||
|
||||
const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations(
|
||||
"Org",
|
||||
{ take: 300 }
|
||||
);
|
||||
|
||||
const {
|
||||
employeesResponseByOrg,
|
||||
isLoadingEmployeesByOrg: isLoadingEmployees,
|
||||
} = useEmployees({
|
||||
organizationId: selectedOrg || undefined,
|
||||
params: { take: 3000, skip: 0 },
|
||||
});
|
||||
|
||||
const filteredEmployees = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
const employees = employeesResponseByOrg?.items ?? [];
|
||||
|
||||
if (!query) return employees;
|
||||
|
||||
return employees.filter((employee) => {
|
||||
const name = localizedName(employee.user.name).toLowerCase();
|
||||
const email = employee.user.email?.toLowerCase() ?? "";
|
||||
|
||||
return name.includes(query) || email.includes(query);
|
||||
});
|
||||
}, [employeesResponseByOrg, localizedName, search]);
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedOrg("");
|
||||
setSelectedUser("");
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (isAssigning) return;
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleOrganizationChange = (organizationId: string) => {
|
||||
setSelectedOrg(organizationId);
|
||||
setSelectedUser("");
|
||||
setSearch("");
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!selectedOrg || !selectedUser) {
|
||||
toast.error(t("organization.allFieldsRequired", "All fields are required"));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: RemoveOrAssignOrgAdminPayload = {
|
||||
organizationId: selectedOrg,
|
||||
userId: selectedUser,
|
||||
};
|
||||
|
||||
setIsAssigning(true);
|
||||
|
||||
try {
|
||||
await assignOrgAdminRole(payload);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["organizationAdmins"],
|
||||
});
|
||||
resetForm();
|
||||
onClose();
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
toast.error(t("organization.userAssignFailed"), {
|
||||
description: error?.response?.data?.message,
|
||||
});
|
||||
} finally {
|
||||
setIsAssigning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[750px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(
|
||||
"organization.assignAdminToOrganization",
|
||||
"Assign admin to organization"
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"organization.assignOrgAdminInstructions",
|
||||
"Select an organization and a user to assign as its administrator."
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("organization.organizations")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
{isLoadingOrgs ? (
|
||||
<div className="flex h-[350px] items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[350px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
|
||||
<div className="space-y-1">
|
||||
{organizationsResponse?.items?.map((organization) => (
|
||||
<button
|
||||
key={organization.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleOrganizationChange(organization.id)
|
||||
}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||
selectedOrg === organization.id
|
||||
? "bg-green-200 text-green-800 dark:bg-green-900/40 dark:text-green-300"
|
||||
: "text-gray-700 hover:bg-green-100 dark:text-gray-300 dark:hover:bg-green-900/30"
|
||||
)}
|
||||
>
|
||||
<span>{localizedName(organization.name)}</span>
|
||||
{selectedOrg === organization.id && (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("organization.users")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
</Label>
|
||||
{!selectedOrg ? (
|
||||
<div className="flex h-[350px] items-center justify-center rounded-md border border-gray-200 p-4 text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
<p className="text-center text-sm">
|
||||
{t("organization.selectOrganizationFirst")}
|
||||
</p>
|
||||
</div>
|
||||
) : isLoadingEmployees ? (
|
||||
<div className="flex h-[350px] items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t(
|
||||
"organization.searchOrganizationUsers",
|
||||
"Search organization users"
|
||||
)}
|
||||
/>
|
||||
<ScrollArea className="h-[308px] rounded-md border border-gray-200 p-2 dark:border-gray-700">
|
||||
<div className="space-y-1">
|
||||
{filteredEmployees.map((employee) => (
|
||||
<button
|
||||
key={employee.user.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedUser(employee.user.id)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors",
|
||||
selectedUser === employee.user.id
|
||||
? "bg-purple-200 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300"
|
||||
: "text-gray-700 hover:bg-purple-100 dark:text-gray-300 dark:hover:bg-purple-900/30"
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">
|
||||
{localizedName(employee.user.name) ||
|
||||
employee.user.email}
|
||||
</p>
|
||||
<p className="truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{employee.user.email}
|
||||
</p>
|
||||
</div>
|
||||
{selectedUser === employee.user.id && (
|
||||
<Check className="ml-2 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{filteredEmployees.length === 0 && (
|
||||
<p className="mt-2 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("organization.noUsersFound")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isAssigning}
|
||||
>
|
||||
{t("common.Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isAssigning || !selectedOrg || !selectedUser}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{isAssigning && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{isAssigning
|
||||
? t("organization.assigning")
|
||||
: t("organization.assignUser")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,243 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,134 +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"));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Plus, Loader2, UserPlus } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../shared/common/ui/card";
|
||||
import { toast } from "sonner";
|
||||
import { AdvancedTable } from "../../../shared/common/ui/table/AdvancedTable";
|
||||
import { OrganizationAdminsColumnDefn } from "./OrganizationAdminsColumnDefn";
|
||||
import { useOrganizations } from "@/super-admin/hooks/useOrganizations";
|
||||
import { AssignOrgAdminDialog } from "./AssignOrgAdminDialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
|
||||
export default function OrganizationAdmins() {
|
||||
const [pageIndex, setPageIndex] = useState(0); // starts at 0
|
||||
const pageSize = 10;
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const { organizationsAdminsResponse, isLoading, isError, refetch } =
|
||||
useOrganizations("Admin", {
|
||||
take: pageSize,
|
||||
skip: pageIndex * pageSize,
|
||||
orderBy: "createdAt",
|
||||
order: "createdAt:Desc",
|
||||
name: searchTerm || undefined,
|
||||
});
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPageIndex(newPage);
|
||||
};
|
||||
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
setPageIndex(0); // Reset to first page when search term changes
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetch();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center h-64">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("organization.loadingAdmins")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center h-64">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="text-red-500 font-medium">
|
||||
{t("organization.errorLoadingAdmins")}
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => refetch()}>
|
||||
{t("organization.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-6 space-y-6 dark:bg-gray-900">
|
||||
<Card className="col-span-2 shadow-none border-none bg-transparent dark:bg-transparent px-0">
|
||||
<CardHeader className="flex flex-row justify-between items-center px-0">
|
||||
<CardTitle className="text-xl font-semibold dark:text-white">
|
||||
{t("organization.organizationAdmins")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 dark:bg-transparent">
|
||||
<AdvancedTable
|
||||
columns={OrganizationAdminsColumnDefn(
|
||||
localizedName as (name?: { am?: string; en?: string }) => string
|
||||
)}
|
||||
data={organizationsAdminsResponse?.items || []}
|
||||
tableName="Organization Admins"
|
||||
toolBarPosition="right"
|
||||
refresh={handleRefresh}
|
||||
onGlobalFilterChange={handleSearchChange}
|
||||
disableClientFiltering={true}
|
||||
extraToolbar={
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setIsAssignDialogOpen(true)}
|
||||
className="bg-[#4A6CF7] hover:bg-[#3a5ad4] text-white px-5 py-2 rounded-md text-sm font-medium shadow-md"
|
||||
>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
{t("organization.assignAdmin")}
|
||||
</Button>
|
||||
<Link to="/user-management/add_admin">
|
||||
<Button className="px-5 py-2 rounded-md text-sm font-medium shadow-md">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("organization.addAdmin")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
itemCount={organizationsAdminsResponse?.count || 0}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={handlePageChange}
|
||||
nextFunction={() => handlePageChange(pageIndex + 1)}
|
||||
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Assign Admin Dialog */}
|
||||
<AssignOrgAdminDialog
|
||||
isOpen={isAssignDialogOpen}
|
||||
onClose={() => setIsAssignDialogOpen(false)}
|
||||
onSuccess={() => {
|
||||
refetch();
|
||||
toast.success(t("organization.adminAssignedSuccess"));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,80 +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;
|
||||
import React from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../../shared/common/ui/dropdown-menu";
|
||||
import { Edit, MoreHorizontal, Trash, UserCheck, UserX } from "lucide-react";
|
||||
import { Button } from "../../../shared/common/ui/button";
|
||||
import { Link } from "react-router-dom";
|
||||
import { OrganizationAdmin } from "@/super-admin/services/api/organizationAdminService";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface OrganizationAdminsActionsProps {
|
||||
rowData: OrganizationAdmin;
|
||||
}
|
||||
const OrganizationAdminsActions: React.FC<OrganizationAdminsActionsProps> = ({
|
||||
rowData,
|
||||
}) => {
|
||||
const handleDeleteClick = () => {
|
||||
toast.warning("Delete functionality not implemented yet");
|
||||
};
|
||||
|
||||
const handleStatusChange = (status: string) => {
|
||||
toast.info(`Admin status change to ${status} not implemented yet`);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
to={`/add_admin?id=${rowData.id}`}
|
||||
className="flex items-center w-full"
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{rowData.status !== "active" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange("active")}
|
||||
className="flex items-center"
|
||||
>
|
||||
<UserCheck className="mr-2 h-4 w-4" />
|
||||
Activate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{rowData.status === "active" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleStatusChange("inactive")}
|
||||
className="flex items-center"
|
||||
>
|
||||
<UserX className="mr-2 h-4 w-4" />
|
||||
Deactivate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteClick()}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationAdminsActions;
|
||||
|
||||
@@ -1,77 +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>
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
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>
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,59 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,50 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,272 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,62 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,53 +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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,29 +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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,153 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,263 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,169 +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;
|
||||
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;
|
||||
|
||||
@@ -1,122 +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} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
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} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,126 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,69 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,324 +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;
|
||||
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;
|
||||
|
||||
@@ -1,441 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,213 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,104 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,143 +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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,363 +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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,151 +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;
|
||||
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;
|
||||
|
||||
@@ -1,132 +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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,195 +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>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,402 +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;
|
||||
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;
|
||||
|
||||
@@ -1,23 +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;
|
||||
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;
|
||||
|
||||
@@ -1,105 +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,
|
||||
};
|
||||
};
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,17 +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,
|
||||
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,
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
export interface CreateTemplateTypes {
|
||||
id?: string;
|
||||
sincerelyText: string;
|
||||
body: string;
|
||||
subject: string;
|
||||
key: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
locale: "am" | "en";
|
||||
}
|
||||
export interface CreateTemplateTypes {
|
||||
id?: string;
|
||||
sincerelyText: string;
|
||||
body: string;
|
||||
subject: string;
|
||||
key: string;
|
||||
name: {
|
||||
am: string;
|
||||
en: string;
|
||||
};
|
||||
locale: "am" | "en";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user