This commit is contained in:
natib21
2026-07-10 11:25:59 +00:00
parent 2696ca7498
commit e6e44e773b
1146 changed files with 200266 additions and 90527 deletions

View File

@@ -1,221 +1,221 @@
import React, { useState } from "react";
import { usePendingUsers } from "@/user-management/userManagement/hooks/usePendingUsersHook";
import { toast } from "sonner";
import { Check, X, Users, Search, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const ReviewAllPendingUsers = () => {
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const [searchTerm, setSearchTerm] = useState("");
const { pendingUsers, loadingPendingUsers, approveMutation, rejectMutation } =
usePendingUsers({ take: 50, skip: 0 });
const handleApprove = (id: string) => {
approveMutation.mutate(id, {
onSuccess: () => toast.success(t("pendingUsers.employeeApprovedSuccess")),
onError: (error) => handleError(error),
});
};
const handleReject = (id: string) => {
rejectMutation.mutate(id, {
onSuccess: () => toast.success(t("pendingUsers.employeeRejectedSuccess")),
onError: (error) => handleError(error),
});
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
// Integrate search with your API if needed
};
// Loading skeleton (full width)
if (loadingPendingUsers) {
return (
<div className="p-6 w-full">
{/* Header skeleton */}
<div className="flex justify-between items-center mb-6">
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse"></div>
<div className="h-6 w-24 bg-gray-200 rounded animate-pulse"></div>
</div>
{/* Search bar skeleton */}
<div className="flex gap-2 mb-6">
<div className="flex-1 h-10 bg-gray-200 rounded animate-pulse"></div>
<div className="w-20 h-10 bg-gray-200 rounded animate-pulse"></div>
</div>
{/* Table skeleton */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
{[
t("pendingUsers.name"),
t("pendingUsers.email"),
t("pendingUsers.positions"),
t("pendingUsers.actions"),
].map((h) => (
<th
key={h}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(5)].map((_, i) => (
<tr
key={i}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="px-6 py-4">
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
<td className="px-6 py-4">
<div className="h-4 w-40 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
<td className="px-6 py-4">
<div className="h-4 w-48 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<div className="h-8 w-20 bg-gray-200 dark:bg-gray-600 rounded"></div>
<div className="h-8 w-20 bg-gray-200 dark:bg-gray-600 rounded"></div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// Empty state
if (!pendingUsers?.items?.length) {
return (
<div className="p-6 w-full">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Users className="h-6 w-6 text-blue-600" />
{t("pendingUsers.title")}
</h1>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-12 text-center mt-6">
<div className="flex justify-center mb-4">
<div className="bg-primary-100 p-3 rounded-full">
<Users className="h-8 w-8 text-primary-600" />
</div>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
{t("pendingUsers.noPendingApprovals")}
</h3>
<p className="text-gray-500 mb-6">
{t("pendingUsers.allRequestsReviewed")}
</p>
<button
onClick={() => window.location.reload()}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
<RefreshCw className="h-4 w-4 mr-2" />
{t("pendingUsers.refresh")}
</button>
</div>
</div>
);
}
return (
<div className="p-6 w-full">
{/* Header with count */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Users className="h-6 w-6 text-blue-600" />
{t("pendingUsers.title")}
</h1>
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
{pendingUsers.count} {t("pendingUsers.pending")}
</span>
</div>
{/* Table - full width */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden mt-6">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("pendingUsers.name")}
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("pendingUsers.email")}
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("pendingUsers.positions")}
</th>
<th
scope="col"
className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("pendingUsers.actions")}
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{pendingUsers.items.map((emp: any) => (
<tr
key={emp.id}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
{emp.name?.en}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{emp.user?.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{emp.employeePositions
?.map((pos: any) => pos.position?.name?.en)
.join(", ") || "—"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-center">
<div className="flex justify-center gap-2">
<button
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
disabled={approveMutation.isPending}
onClick={() => handleApprove(emp.id)}>
<Check className="h-3.5 w-3.5 mr-1" />
{t("pendingUsers.approve")}
</button>
<button
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
disabled={rejectMutation.isPending}
onClick={() => handleReject(emp.id)}>
<X className="h-3.5 w-3.5 mr-1" />
{t("pendingUsers.reject")}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Footer note */}
<p className="text-xs text-gray-400 mt-4 text-center">
{t("pendingUsers.showingUpTo")}
</p>
</div>
);
};
export default ReviewAllPendingUsers;
import React, { useState } from "react";
import { usePendingUsers } from "@/user-management/userManagement/hooks/usePendingUsersHook";
import { toast } from "sonner";
import { Check, X, Users, Search, RefreshCw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
const ReviewAllPendingUsers = () => {
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
const [searchTerm, setSearchTerm] = useState("");
const { pendingUsers, loadingPendingUsers, approveMutation, rejectMutation } =
usePendingUsers({ take: 50, skip: 0 });
const handleApprove = (id: string) => {
approveMutation.mutate(id, {
onSuccess: () => toast.success(t("pendingUsers.employeeApprovedSuccess")),
onError: (error) => handleError(error),
});
};
const handleReject = (id: string) => {
rejectMutation.mutate(id, {
onSuccess: () => toast.success(t("pendingUsers.employeeRejectedSuccess")),
onError: (error) => handleError(error),
});
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
// Integrate search with your API if needed
};
// Loading skeleton (full width)
if (loadingPendingUsers) {
return (
<div className="p-6 w-full">
{/* Header skeleton */}
<div className="flex justify-between items-center mb-6">
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse"></div>
<div className="h-6 w-24 bg-gray-200 rounded animate-pulse"></div>
</div>
{/* Search bar skeleton */}
<div className="flex gap-2 mb-6">
<div className="flex-1 h-10 bg-gray-200 rounded animate-pulse"></div>
<div className="w-20 h-10 bg-gray-200 rounded animate-pulse"></div>
</div>
{/* Table skeleton */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
{[
t("pendingUsers.name"),
t("pendingUsers.email"),
t("pendingUsers.positions"),
t("pendingUsers.actions"),
].map((h) => (
<th
key={h}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(5)].map((_, i) => (
<tr
key={i}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="px-6 py-4">
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
<td className="px-6 py-4">
<div className="h-4 w-40 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
<td className="px-6 py-4">
<div className="h-4 w-48 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<div className="h-8 w-20 bg-gray-200 dark:bg-gray-600 rounded"></div>
<div className="h-8 w-20 bg-gray-200 dark:bg-gray-600 rounded"></div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// Empty state
if (!pendingUsers?.items?.length) {
return (
<div className="p-6 w-full">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Users className="h-6 w-6 text-blue-600" />
{t("pendingUsers.title")}
</h1>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-12 text-center mt-6">
<div className="flex justify-center mb-4">
<div className="bg-primary-100 p-3 rounded-full">
<Users className="h-8 w-8 text-primary-600" />
</div>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
{t("pendingUsers.noPendingApprovals")}
</h3>
<p className="text-gray-500 mb-6">
{t("pendingUsers.allRequestsReviewed")}
</p>
<button
onClick={() => window.location.reload()}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
<RefreshCw className="h-4 w-4 mr-2" />
{t("pendingUsers.refresh")}
</button>
</div>
</div>
);
}
return (
<div className="p-6 w-full">
{/* Header with count */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<Users className="h-6 w-6 text-blue-600" />
{t("pendingUsers.title")}
</h1>
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
{pendingUsers.count} {t("pendingUsers.pending")}
</span>
</div>
{/* Table - full width */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden mt-6">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("pendingUsers.name")}
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("pendingUsers.email")}
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("pendingUsers.positions")}
</th>
<th
scope="col"
className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{t("pendingUsers.actions")}
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{pendingUsers.items.map((emp: any) => (
<tr
key={emp.id}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
{emp.name?.en}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{emp.user?.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{emp.employeePositions
?.map((pos: any) => pos.position?.name?.en)
.join(", ") || "—"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-center">
<div className="flex justify-center gap-2">
<button
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
disabled={approveMutation.isPending}
onClick={() => handleApprove(emp.id)}>
<Check className="h-3.5 w-3.5 mr-1" />
{t("pendingUsers.approve")}
</button>
<button
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
disabled={rejectMutation.isPending}
onClick={() => handleReject(emp.id)}>
<X className="h-3.5 w-3.5 mr-1" />
{t("pendingUsers.reject")}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Footer note */}
<p className="text-xs text-gray-400 mt-4 text-center">
{t("pendingUsers.showingUpTo")}
</p>
</div>
);
};
export default ReviewAllPendingUsers;

View File

@@ -1,85 +1,85 @@
import { useLocalizedName } from "@/shared/common/localizedName";
import { ItemDTO, OrganizationUserDto, User } from "@/shared/dto/user/usersDto";
import { ColumnDef } from "@tanstack/react-table";
import ArchivedUserActionsCell from "./ArchivedUsers/ArchivedUserActions";
import { format } from "date-fns/format";
import { t } from "i18next";
import { Badge } from "@/shared/common/ui/badge";
export const ArchivedUserColumnDefn: ColumnDef<ItemDTO>[] = [
{
accessorKey: "name",
header: () => t("setting.Name"),
cell: ({ row }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const localizedName = useLocalizedName();
const name = row.original?.name;
return <span>{localizedName(name)}</span>;
},
},
// {
// accessorKey: "username",
// header: "Username",
// cell: ({ row }) => {
// const username = row.original.username;
// return <span>{username}</span>;
// },
// },
// {
// accessorKey: "createdAt",
// header: "Created At",
// cell: ({ row }) => {
// const date = row.original.createdAt;
// return <span>{format(new Date(date), "MMM d, yyyy HH:mm")}</span>;
// },
// },
{
accessorKey: "status",
header: () => t("userRecord.Status"),
cell: ({ row }) => {
const status = row.original?.status;
const getStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case "inactive":
return "bg-red-100 text-red-600 hover:bg-red-100"; // red for inactive
case "active":
return "bg-primary-100 text-primary-600 hover:bg-primary-100"; // green for active
default:
return "bg-gray-100 text-gray-600 hover:bg-gray-100";
}
};
const getStatusText = (status: string) => {
switch (status.toLowerCase()) {
case "inactive":
return "InActive";
case "active":
return "Active";
default:
return "Not Available";
}
};
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 }) => <ArchivedUserActionsCell row={row.original} />,
},
];
import { useLocalizedName } from "@/shared/common/localizedName";
import { ItemDTO, OrganizationUserDto, User } from "@/shared/dto/user/usersDto";
import { ColumnDef } from "@tanstack/react-table";
import ArchivedUserActionsCell from "./ArchivedUsers/ArchivedUserActions";
import { format } from "date-fns/format";
import { t } from "i18next";
import { Badge } from "@/shared/common/ui/badge";
export const ArchivedUserColumnDefn: ColumnDef<ItemDTO>[] = [
{
accessorKey: "name",
header: () => t("setting.Name"),
cell: ({ row }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const localizedName = useLocalizedName();
const name = row.original?.name;
return <span>{localizedName(name)}</span>;
},
},
// {
// accessorKey: "username",
// header: "Username",
// cell: ({ row }) => {
// const username = row.original.username;
// return <span>{username}</span>;
// },
// },
// {
// accessorKey: "createdAt",
// header: "Created At",
// cell: ({ row }) => {
// const date = row.original.createdAt;
// return <span>{format(new Date(date), "MMM d, yyyy HH:mm")}</span>;
// },
// },
{
accessorKey: "status",
header: () => t("userRecord.Status"),
cell: ({ row }) => {
const status = row.original?.status;
const getStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case "inactive":
return "bg-red-100 text-red-600 hover:bg-red-100"; // red for inactive
case "active":
return "bg-primary-100 text-primary-600 hover:bg-primary-100"; // green for active
default:
return "bg-gray-100 text-gray-600 hover:bg-gray-100";
}
};
const getStatusText = (status: string) => {
switch (status.toLowerCase()) {
case "inactive":
return "InActive";
case "active":
return "Active";
default:
return "Not Available";
}
};
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 }) => <ArchivedUserActionsCell row={row.original} />,
},
];

View File

@@ -1,78 +1,78 @@
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { useEmployeePositions } from "@/user-management/hooks/useEmployeePostions";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
interface ActivateArchivedUserProps {
isOpen: boolean;
onClose: () => void;
userId: string;
}
const ActivateArchivedUser: React.FC<ActivateArchivedUserProps> = ({
isOpen,
onClose,
userId,
}) => {
const { activateUser, isActivatingUser } = useEmployeePositions();
const [isActivatedUser, setIsActivatedUser] = useState(false);
const {t} = useTranslation()
const {handleError } = useErrorHandler(t)
const onActviate = async () => {
try {
await activateUser({
payload: userId,
successCallback: () => {
onClose();
},
});
setIsActivatedUser(true);
}
catch (error) {
console.error("Error activating user:", error);
handleError(error);
}
}
return (
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Activate Archived User?
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to activate this archived user? This action will restore the user's access and data within the organization.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isActivatedUser}>
Cancel
</AlertDialogCancel>
<Button
variant="default"
onClick={onActviate}
disabled={isActivatedUser}>
{isActivatingUser && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Activate
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default ActivateArchivedUser;
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { useEmployeePositions } from "@/user-management/hooks/useEmployeePostions";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
interface ActivateArchivedUserProps {
isOpen: boolean;
onClose: () => void;
userId: string;
}
const ActivateArchivedUser: React.FC<ActivateArchivedUserProps> = ({
isOpen,
onClose,
userId,
}) => {
const { activateUser, isActivatingUser } = useEmployeePositions();
const [isActivatedUser, setIsActivatedUser] = useState(false);
const {t} = useTranslation()
const {handleError } = useErrorHandler(t)
const onActviate = async () => {
try {
await activateUser({
payload: userId,
successCallback: () => {
onClose();
},
});
setIsActivatedUser(true);
}
catch (error) {
console.error("Error activating user:", error);
handleError(error);
}
}
return (
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Activate Archived User?
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to activate this archived user? This action will restore the user's access and data within the organization.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isActivatedUser}>
Cancel
</AlertDialogCancel>
<Button
variant="default"
onClick={onActviate}
disabled={isActivatedUser}>
{isActivatingUser && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Activate
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default ActivateArchivedUser;

View File

@@ -1,104 +1,104 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/shared/common/ui/dropdown-menu"; // Adjust if needed
import { Button } from "@/shared/common/ui/button";
import { MoreVertical, Edit, Trash, UserPlus } from "lucide-react"; // Adjust icons as needed
import { ItemDTO, User } from "@/shared/dto/user/usersDto";
import { t } from "i18next";
import ActivateArchivedUser from "./ActivateArchivedUser";
type ActionsCellProps = {
row: ItemDTO;
};
const ArchivedUserActionsCell: React.FC<ActionsCellProps> = ({ row }) => {
const navigate = useNavigate();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const handleEdit = () => {
navigate(`/user-management/archive/edit/${row?.userId}`);
};
const handleDelete = (e: Event) => {
e.preventDefault();
setDropdownOpen(false);
setShowDeleteDialog(true);
};
const handleActivateModal = () => {
setModalOpen(true);
};
return (
<>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Open actions menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onInteractOutside={(e) => {
const target = e.target as HTMLElement;
if (!target.closest('[role="dialog"]')) {
setDropdownOpen(false);
}
}}>
<DropdownMenuLabel>{t("userRecord.Actions")}</DropdownMenuLabel>
<DropdownMenuItem
onSelect={handleEdit}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<Edit className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span> {t("userRecord.Edit")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleActivateModal}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<UserPlus className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span> {t("userRecord.Activate")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={handleDelete}
className="text-red-600 cursor-pointer hover:!text-red-800 !bg-transparent !transition-colors duration-200">
<Trash className="mr-2 h-4 w-4 text-red-600 group-hover:text-white transition-colors duration-200" />
<span> {t("userRecord.Delete")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{modalOpen && (
<ActivateArchivedUser
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
userId={row.id}
/>
)}
{/* {showDeleteDialog && (
<DeletePositionTypes
id={row.id}
onSuccess={() => setShowDeleteDialog(false)}
onClose={() => setShowDeleteDialog(false)}
/>
)} */}
</>
);
};
export default ArchivedUserActionsCell;
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/shared/common/ui/dropdown-menu"; // Adjust if needed
import { Button } from "@/shared/common/ui/button";
import { MoreVertical, Edit, Trash, UserPlus } from "lucide-react"; // Adjust icons as needed
import { ItemDTO, User } from "@/shared/dto/user/usersDto";
import { t } from "i18next";
import ActivateArchivedUser from "./ActivateArchivedUser";
type ActionsCellProps = {
row: ItemDTO;
};
const ArchivedUserActionsCell: React.FC<ActionsCellProps> = ({ row }) => {
const navigate = useNavigate();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const handleEdit = () => {
navigate(`/user-management/archive/edit/${row?.userId}`);
};
const handleDelete = (e: Event) => {
e.preventDefault();
setDropdownOpen(false);
setShowDeleteDialog(true);
};
const handleActivateModal = () => {
setModalOpen(true);
};
return (
<>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Open actions menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onInteractOutside={(e) => {
const target = e.target as HTMLElement;
if (!target.closest('[role="dialog"]')) {
setDropdownOpen(false);
}
}}>
<DropdownMenuLabel>{t("userRecord.Actions")}</DropdownMenuLabel>
<DropdownMenuItem
onSelect={handleEdit}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<Edit className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span> {t("userRecord.Edit")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={handleActivateModal}
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
<UserPlus className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
<span> {t("userRecord.Activate")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={handleDelete}
className="text-red-600 cursor-pointer hover:!text-red-800 !bg-transparent !transition-colors duration-200">
<Trash className="mr-2 h-4 w-4 text-red-600 group-hover:text-white transition-colors duration-200" />
<span> {t("userRecord.Delete")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{modalOpen && (
<ActivateArchivedUser
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
userId={row.id}
/>
)}
{/* {showDeleteDialog && (
<DeletePositionTypes
id={row.id}
onSuccess={() => setShowDeleteDialog(false)}
onClose={() => setShowDeleteDialog(false)}
/>
)} */}
</>
);
};
export default ArchivedUserActionsCell;

View File

@@ -1,268 +1,268 @@
import React, { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Card } from "@/shared/common/ui/card";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@radix-ui/react-label";
import { Plus, Trash2, Pencil } from "lucide-react";
import {
prefixSuffixService,
RemarkPayload,
} from "@/user-management/services/api/prefixSuffixService";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/common/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRemarkByUnitId } from "@/super-admin/hooks/useRemark";
import { t } from "i18next";
interface Props {
unitId: string;
}
interface RemarkDto {
id: string;
remark: string;
description?: string;
}
export const CommonRemarks = ({ unitId }: Props) => {
const [remark, setRemark] = useState("");
const [description, setDescription] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deleteRemarkId, setDeleteRemarkId] = useState<string | null>(null);
const [error, setError] = useState("");
const queryClient = useQueryClient();
const { data: remarksData, isLoading: isRemarkLoading } =
useRemarkByUnitId(unitId,{
skip: 0,
take: 300,
});
// Save/Edit Remark
const saveRemarkMutation = useMutation({
mutationFn: async () => {
if (!remark) throw new Error(t("contentManagement.remarkRequired"));
const payload: RemarkPayload = { unitId, remark, description };
if (editingId) {
return prefixSuffixService.editRemark(editingId, payload);
}
return prefixSuffixService.createRemark(payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["remark", unitId] });
resetForm();
setIsDialogOpen(false);
},
onError: (err: any) =>
setError(err.message || t("contentManagement.failed")),
});
// Delete Remark
const deleteRemarkMutation = useMutation({
mutationFn: (id: string) => prefixSuffixService.deleteRemarks(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["remark", unitId] });
setIsDeleteDialogOpen(false);
setDeleteRemarkId(null);
},
onError: () => setError(t("contentManagement.failedToDelete")),
});
const resetForm = () => {
setRemark("");
setDescription("");
setEditingId(null);
setError("");
};
const handleEdit = (item: RemarkDto) => {
setEditingId(item.id);
setRemark(item.remark);
setDescription(item.description || "");
setIsDialogOpen(true);
};
const handleSubmit = () => saveRemarkMutation.mutate();
return (
<Card className="p-4 border shadow-sm space-y-4 dark:border-gray-700 dark:bg-gray-800">
{/* Add/Edit Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="w-[180px] bg-primary hover:bg-primary/90 text-primary-foreground flex items-center">
<Plus className="h-4 w-4 mr-2" />
{t("contentManagement.addRemark")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl w-full dark:bg-gray-800">
<DialogHeader>
<DialogTitle className="dark:text-white">
{editingId
? t("contentManagement.editRemark")
: t("contentManagement.addRemark")}
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex flex-col">
<Label htmlFor="remark" className="mb-1 dark:text-gray-200">
{t("header.Remark")}
</Label>
<Input
id="remark"
value={remark}
onChange={(e) => setRemark(e.target.value)}
placeholder={t("header.Remark")}
disabled={isRemarkLoading}
className="w-full dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
<Label htmlFor="description" className="mb-1 dark:text-gray-200">
{t("header.Description")}
</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("header.Description")}
disabled={isRemarkLoading}
className="w-full mb-2 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
onClick={() => {
resetForm();
setIsDialogOpen(false);
}}>
{t("common.Cancel")}
</Button>
<Button
onClick={handleSubmit}
disabled={isRemarkLoading} // ✅ access via mutation object
className="bg-primary hover:bg-primary/90 text-primary-foreground">
{isRemarkLoading
? t("contentManagement.saving")
: editingId
? t("delegation.update")
: t("delegation.save")}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Remarks Table */}
<h3 className="font-semibold text-md dark:text-white">
{t("contentManagement.commonRemarks")}
</h3>
{isRemarkLoading ? (
<div className="text-center py-4 dark:text-gray-400">{t("contentManagement.loading")}</div>
) : (
<div className="overflow-x-auto max-h-[380px] overflow-y-auto border rounded dark:border-gray-600">
<table className="min-w-full text-sm text-left">
<thead>
<tr className="border-b bg-gray-100 dark:bg-gray-700">
<th className="px-3 py-2 dark:text-white">#</th>
<th className="px-3 py-2 dark:text-white">{t("header.Remark")}</th>
<th className="px-3 py-2 dark:text-white">{t("header.Description")}</th>
<th className="px-3 py-2 dark:text-white">{t("userRecord.Actions")}</th>
</tr>
</thead>
<tbody>
{remarksData?.items.length ? (
remarksData.items.map((item: any, idx: any) => (
<tr key={item.id} className="border-b hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600">
<td className="px-3 py-2 dark:text-gray-300">{idx + 1}</td>
<td className="px-3 py-2 dark:text-gray-300">{item.remark}</td>
<td className="px-3 py-2 dark:text-gray-300">{item.description}</td>
<td className="px-3 py-2 flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => {
setDeleteRemarkId(item.id);
setIsDeleteDialogOpen(true);
}}>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="text-center py-4 text-gray-500 dark:text-gray-400">
{t("contentManagement.noRec")}
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
<AlertDialog
open={isDeleteDialogOpen}
onOpenChange={(open) => {
if (deleteRemarkMutation.isPending) return;
setIsDeleteDialogOpen(open);
if (!open) setDeleteRemarkId(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.delete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("contentManagement.deleteMsg")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteRemarkMutation.isPending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
disabled={deleteRemarkMutation.isPending || !deleteRemarkId}
onClick={() => {
if (!deleteRemarkId) return;
deleteRemarkMutation.mutate(deleteRemarkId);
}}
>
{deleteRemarkMutation.isPending
? t("common.deleting", "Deleting...")
: t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
);
};
import React, { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Card } from "@/shared/common/ui/card";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@radix-ui/react-label";
import { Plus, Trash2, Pencil } from "lucide-react";
import {
prefixSuffixService,
RemarkPayload,
} from "@/user-management/services/api/prefixSuffixService";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/common/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/common/ui/alert-dialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRemarkByUnitId } from "@/super-admin/hooks/useRemark";
import { t } from "i18next";
interface Props {
unitId: string;
}
interface RemarkDto {
id: string;
remark: string;
description?: string;
}
export const CommonRemarks = ({ unitId }: Props) => {
const [remark, setRemark] = useState("");
const [description, setDescription] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deleteRemarkId, setDeleteRemarkId] = useState<string | null>(null);
const [error, setError] = useState("");
const queryClient = useQueryClient();
const { data: remarksData, isLoading: isRemarkLoading } =
useRemarkByUnitId(unitId,{
skip: 0,
take: 300,
});
// Save/Edit Remark
const saveRemarkMutation = useMutation({
mutationFn: async () => {
if (!remark) throw new Error(t("contentManagement.remarkRequired"));
const payload: RemarkPayload = { unitId, remark, description };
if (editingId) {
return prefixSuffixService.editRemark(editingId, payload);
}
return prefixSuffixService.createRemark(payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["remark", unitId] });
resetForm();
setIsDialogOpen(false);
},
onError: (err: any) =>
setError(err.message || t("contentManagement.failed")),
});
// Delete Remark
const deleteRemarkMutation = useMutation({
mutationFn: (id: string) => prefixSuffixService.deleteRemarks(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["remark", unitId] });
setIsDeleteDialogOpen(false);
setDeleteRemarkId(null);
},
onError: () => setError(t("contentManagement.failedToDelete")),
});
const resetForm = () => {
setRemark("");
setDescription("");
setEditingId(null);
setError("");
};
const handleEdit = (item: RemarkDto) => {
setEditingId(item.id);
setRemark(item.remark);
setDescription(item.description || "");
setIsDialogOpen(true);
};
const handleSubmit = () => saveRemarkMutation.mutate();
return (
<Card className="p-4 border shadow-sm space-y-4 dark:border-gray-700 dark:bg-gray-800">
{/* Add/Edit Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="w-[180px] bg-primary hover:bg-primary/90 text-primary-foreground flex items-center">
<Plus className="h-4 w-4 mr-2" />
{t("contentManagement.addRemark")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl w-full dark:bg-gray-800">
<DialogHeader>
<DialogTitle className="dark:text-white">
{editingId
? t("contentManagement.editRemark")
: t("contentManagement.addRemark")}
</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="flex flex-col">
<Label htmlFor="remark" className="mb-1 dark:text-gray-200">
{t("header.Remark")}
</Label>
<Input
id="remark"
value={remark}
onChange={(e) => setRemark(e.target.value)}
placeholder={t("header.Remark")}
disabled={isRemarkLoading}
className="w-full dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
<Label htmlFor="description" className="mb-1 dark:text-gray-200">
{t("header.Description")}
</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("header.Description")}
disabled={isRemarkLoading}
className="w-full mb-2 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<Button
variant="outline"
onClick={() => {
resetForm();
setIsDialogOpen(false);
}}>
{t("common.Cancel")}
</Button>
<Button
onClick={handleSubmit}
disabled={isRemarkLoading} // ✅ access via mutation object
className="bg-primary hover:bg-primary/90 text-primary-foreground">
{isRemarkLoading
? t("contentManagement.saving")
: editingId
? t("delegation.update")
: t("delegation.save")}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Remarks Table */}
<h3 className="font-semibold text-md dark:text-white">
{t("contentManagement.commonRemarks")}
</h3>
{isRemarkLoading ? (
<div className="text-center py-4 dark:text-gray-400">{t("contentManagement.loading")}</div>
) : (
<div className="overflow-x-auto max-h-[380px] overflow-y-auto border rounded dark:border-gray-600">
<table className="min-w-full text-sm text-left">
<thead>
<tr className="border-b bg-gray-100 dark:bg-gray-700">
<th className="px-3 py-2 dark:text-white">#</th>
<th className="px-3 py-2 dark:text-white">{t("header.Remark")}</th>
<th className="px-3 py-2 dark:text-white">{t("header.Description")}</th>
<th className="px-3 py-2 dark:text-white">{t("userRecord.Actions")}</th>
</tr>
</thead>
<tbody>
{remarksData?.items.length ? (
remarksData.items.map((item: any, idx: any) => (
<tr key={item.id} className="border-b hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600">
<td className="px-3 py-2 dark:text-gray-300">{idx + 1}</td>
<td className="px-3 py-2 dark:text-gray-300">{item.remark}</td>
<td className="px-3 py-2 dark:text-gray-300">{item.description}</td>
<td className="px-3 py-2 flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => {
setDeleteRemarkId(item.id);
setIsDeleteDialogOpen(true);
}}>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="text-center py-4 text-gray-500 dark:text-gray-400">
{t("contentManagement.noRec")}
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
<AlertDialog
open={isDeleteDialogOpen}
onOpenChange={(open) => {
if (deleteRemarkMutation.isPending) return;
setIsDeleteDialogOpen(open);
if (!open) setDeleteRemarkId(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("common.delete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("contentManagement.deleteMsg")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteRemarkMutation.isPending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
disabled={deleteRemarkMutation.isPending || !deleteRemarkId}
onClick={() => {
if (!deleteRemarkId) return;
deleteRemarkMutation.mutate(deleteRemarkId);
}}
>
{deleteRemarkMutation.isPending
? t("common.deleting", "Deleting...")
: t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
);
};

View File

@@ -1,468 +1,468 @@
import { useEffect, useState, useRef, useCallback } from "react";
import {
HeaderFooterResponseDto,
headerFooterService,
CreateHeaderFooterPayload,
HeaderFooterChangeStatusPayload,
} from "@/user-management/services/api/headerFooterService";
import { Card } from "@/shared/common/ui/card";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { Label } from "@/shared/common/ui/label";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import i18n from "@/i18n";
import { t } from "i18next";
import { useQueryClient } from "@tanstack/react-query";
import { presignedAxios } from "@/shared/services/presignedAxios";
import useSettings from "@/record-management/components/hooks/useSettings";
import { OrganizationsPositions } from "@/record-management/services/api/departmentService";
import { MultiSelect } from "@/shared/common/ui/multi-select";
import { useUnitConfiguration } from "@/shared/hooks/useUnitConfiguration";
import { KeyValue } from "@/record-management/types/recordSelectTypes";
import { FormSelectField } from "@/shared/common/form/fields/FormFields";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { useLocalizedName } from "@/shared/common/localizedName";
interface Props {
unitId: string;
}
const HeaderAndFooter = ({ unitId }: Props) => {
const [headers, setHeaders] = useState<HeaderFooterResponseDto[]>([]);
const [footers, setFooters] = useState<HeaderFooterResponseDto[]>([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState("");
const [newFile, setNewFile] = useState<File | null>(null);
const [newType, setNewType] = useState<"header" | "footer">("header");
const [newPreview, setNewPreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const queryClient = useQueryClient();
const lang = i18n.language;
const [selectedPositions, setSelectedPositions] = useState<string[]>([]);
const { data: unitConfigData } = useUnitConfiguration(unitId ?? "", {
enabled: !!unitId,
});
const unitConfig = unitConfigData?.data?.items?.[0];
const canCreateDirectRecord = unitConfig?.canCreateDirectRecord ?? false;
const localizedName = useLocalizedName();
const [selectedRecordType, setSelectedRecordType] = useState<string | null>(
null,
);
const isDirectRecordType = (item: {
name?: { en?: string; am?: string };
key?: string;
}) => {
const key = String(item.key || "").toLowerCase();
const rawName = [item.name?.en, item.name?.am]
.filter(Boolean)
.join(" ")
.toLowerCase();
return key.includes("direct") || rawName.includes("direct");
};
const { recordTypes } = useSettings();
const { allDepratments } = useSettings();
const localizedFormName = useCallback(
(name?: { am: string; en: string }) => {
if (!name) return "";
return lang === "am" ? name.am || name.en : name.en || name.am;
},
[lang],
);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [headerListRes, footerListRes] = await Promise.all([
headerFooterService.getHeadersByUnitId(unitId),
headerFooterService.getFootersByUnitId(unitId),
]);
const activeUploadedHeaders = headerListRes.data.items.filter(
(item) => item.isCurrent && item.uploadedSuccessfully,
);
const activeUploadedFooters = footerListRes.data.items.filter(
(item) => item.isCurrent && item.uploadedSuccessfully,
);
const withPresigned = async (
items: HeaderFooterResponseDto[],
type: "header" | "footer",
) =>
Promise.all(
items.map(async (item) => {
try {
const detail =
type === "header"
? await headerFooterService.getHeaderById(item.id)
: await headerFooterService.getFooterById(item.id);
return { ...item, presigned: detail.data.presigned };
} catch {
return item;
}
}),
);
const [activeHeaders, activeFooters] = await Promise.all([
withPresigned(activeUploadedHeaders, "header"),
withPresigned(activeUploadedFooters, "footer"),
]);
setHeaders(activeHeaders);
setFooters(activeFooters);
} catch {
toast.error(t("contentManagement.failedToLoadHeaderFooter"));
} finally {
setLoading(false);
}
}, [unitId]);
const handleStatusChange = async (
id: string,
type: "header" | "footer",
isCurrent: boolean,
) => {
try {
const payload: HeaderFooterChangeStatusPayload = { isCurrent };
if (type === "header") {
await headerFooterService.changeHeaderStatus(id, payload);
} else {
await headerFooterService.changeFooterStatus(id, payload);
}
// Invalidate the query cache so record forms immediately reflect the changes
queryClient.invalidateQueries({
queryKey: ["headers-footers", unitId],
});
toast.success(t("contentManagement.statusChanged"));
fetchData();
} catch {
toast.error(t("contentManagement.statusChangeFailed"));
}
};
const handleUpload = async () => {
if (!newName || !newFile) {
setError(t("contentManagement.provideMsg"));
return;
}
setUploading(true);
setError(null);
try {
const created = await (newType === "header"
? headerFooterService.uploadAndCreateHeader(
newFile,
newName,
unitId,
selectedPositions,
selectedRecordType,
)
: headerFooterService.uploadAndCreateFooter(
newFile,
newName,
unitId,
selectedPositions,
selectedRecordType,
));
// Make the newly uploaded resource active immediately.
if (created?.id) {
if (newType === "header") {
await headerFooterService.changeHeaderStatus(created.id, {
isCurrent: true,
});
} else {
await headerFooterService.changeFooterStatus(created.id, {
isCurrent: true,
});
}
}
toast.success(`${newType} ${t("contentManagement.uploadSuccess")}`);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setNewFile(null);
setNewName("");
setNewPreview(null);
setError(null);
setSelectedPositions([]);
setSelectedRecordType(null);
fetchData();
} catch (err) {
console.error(err);
setError(t("contentManagement.uploadFailed"));
toast.error(t("contentManagement.uploadFailed"));
} finally {
setUploading(false);
}
};
useEffect(() => {
fetchData();
}, [fetchData]);
const recordTypeOptions: KeyValue[] =
recordTypes
?.filter((item: { name?: { en?: string; am?: string }; key?: string }) =>
canCreateDirectRecord ? true : !isDirectRecordType(item),
)
.map((item: { name: { en: string; am: string }; key: string }) => ({
label:
item.key == "direct"
? localizedName({ en: "Direct Letter", am: "ቀጥታ ደብዳቤ" })
: localizedName(item.name),
value: item.key,
})) || [];
const renderList = (
title: string,
items: HeaderFooterResponseDto[],
type: "header" | "footer",
) => (
<Card className="p-6 border shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between mb-6">
<h2 className="font-semibold text-xl dark:text-white">{title}</h2>
<span className="text-sm text-muted-foreground dark:text-gray-400">
{items.length} {items.length === 1 ? "item" : "items"}
</span>
</div>
{loading ? (
<Skeleton className="h-40" />
) : items.length === 0 ? (
<div className="text-center py-8">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
<span className="text-2xl text-gray-400 dark:text-gray-500">
{type === "header" ? "📄" : "📋"}
</span>
</div>
<p className="text-sm text-muted-foreground dark:text-gray-400 mb-2">
No {title.toLowerCase()} found
</p>
<p className="text-xs text-muted-foreground dark:text-gray-500">
Upload your first {type} using the form below
</p>
</div>
) : (
<div className="space-y-4">
{items.map((item) => (
<div
key={item.id}
className={`border rounded-lg p-4 ${
item.isCurrent
? "bg-primary-50 dark:bg-primary-900/30 border-primary-200 dark:border-primary-800 shadow-sm"
: "bg-white dark:bg-gray-700"
}`}>
{/* Header with name and status */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-lg truncate dark:text-white">
{lang === "en" ? item.name.en : item.name.am}
</h3>
{item.isCurrent && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 dark:bg-primary-900/50 text-primary-800 dark:text-primary-300 flex-shrink-0">
{t("contentManagement.currentlyActive")}
</span>
)}
</div>
<p className="text-sm text-muted-foreground dark:text-gray-400 truncate">
{item.fileInfo.fileName}
</p>
</div>
</div>
{/* Preview and Actions */}
<div className="flex items-center justify-between gap-4">
{/* Preview Image */}
{item.presigned && (
<div className="flex-shrink-0">
<div className="w-24 h-16 border rounded-md overflow-hidden bg-gray-50 dark:bg-gray-600">
<img
src={item.presigned}
alt={`${type} Preview`}
className="w-full h-full object-contain"
/>
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 ml-auto">
<Button
size="sm"
variant="outline"
onClick={() => handleStatusChange(item.id, type, false)}
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 whitespace-nowrap">
{t("contentManagement.delete")}
</Button>
</div>
</div>
</div>
))}
</div>
)}
</Card>
);
return (
<div className="space-y-8">
<div className="grid lg:grid-cols-2 gap-8">
{renderList("Headers", headers, "header")}
{renderList("Footers", footers, "footer")}
</div>
<Card className="p-4 border shadow-sm space-y-4 dark:border-gray-700 dark:bg-gray-800">
<h2 className="font-semibold text-lg flex items-center gap-2 dark:text-white">
<Plus className="h-5 w-5" /> {t("contentManagement.addHeaderFooter")}
</h2>
<div className="grid md:grid-cols-3 gap-4">
<div>
<Label className="dark:text-gray-200">
{t("contentManagement.name")}
</Label>
<Input
value={newName}
onChange={(e) => {
setNewName(e.target.value);
setError(null);
}}
placeholder={t("contentManagement.name")}
disabled={uploading}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<Label className="dark:text-gray-200">
{t("contentManagement.type")}
</Label>
<select
value={newType}
onChange={(e) =>
setNewType(e.target.value as "header" | "footer")
}
className="w-full rounded border px-3 py-2 text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
disabled={uploading}>
<option value="header">{t("contentManagement.header")}</option>
<option value="footer">{t("contentManagement.footer")}</option>
</select>
</div>
<div>
<Label className="dark:text-gray-200">
{t("contentManagement.uploadFile")}
</Label>
<Input
type="file"
accept="image/*"
ref={fileInputRef}
onChange={(e) => {
const file = e.target.files?.[0] || null;
if (file) {
const maxSizeMB = 5; // for example, 2 MB
if (file.size > maxSizeMB * 1024 * 1024) {
setError(`File size must be less than ${maxSizeMB} MB`);
if (fileInputRef.current) fileInputRef.current.value = "";
setNewFile(null);
setNewPreview(null);
return;
}
setNewFile(file);
setNewPreview(URL.createObjectURL(file));
} else {
setNewFile(null);
setNewPreview(null);
}
setError(null);
}}
disabled={uploading}
/>
</div>
<div>
<Label className="dark:text-gray-200">
{t("contentManagement.positions")}
</Label>
<MultiSelect
options={
(allDepratments as OrganizationsPositions[] | undefined)?.map(
(dept) => ({
label: localizedFormName(dept.name),
value: dept.id,
}),
) ?? []
}
value={selectedPositions}
onValueChange={setSelectedPositions}
placeholder={t("contentManagement.selectPositions")}
maxCount={5}
className="w-full max-w-full sm:max-w-md overflow-x-auto"
animation={0}
/>
</div>
<div>
<Label className="dark:text-gray-200">
{t("addRecord.Record Type")}
</Label>
<Select
value={selectedRecordType ?? ""}
onValueChange={(val) => setSelectedRecordType(val || null)}>
<SelectTrigger>
<SelectValue placeholder={t("addRecord.Select Record Type")} />
</SelectTrigger>
<SelectContent>
{recordTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{newPreview && (
<div className="mt-2">
<Label className="dark:text-gray-200">
{t("contentManagement.preview")}
</Label>
<div className="border rounded p-2 inline-block mt-1 dark:border-gray-600">
<img
src={newPreview}
alt="Preview"
className="h-24 object-contain rounded"
/>
</div>
</div>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<Button
onClick={handleUpload}
disabled={uploading}
className="bg-primary hover:bg-primary/90 text-primary-foreground w-full">
{uploading
? t("PDF.uploading")
: `${t("signatureUpload.upload")} ${newType}`}
</Button>
</Card>
</div>
);
};
export default HeaderAndFooter;
import { useEffect, useState, useRef, useCallback } from "react";
import {
HeaderFooterResponseDto,
headerFooterService,
CreateHeaderFooterPayload,
HeaderFooterChangeStatusPayload,
} from "@/user-management/services/api/headerFooterService";
import { Card } from "@/shared/common/ui/card";
import { Skeleton } from "@/shared/common/ui/skeleton";
import { Label } from "@/shared/common/ui/label";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import i18n from "@/i18n";
import { t } from "i18next";
import { useQueryClient } from "@tanstack/react-query";
import { presignedAxios } from "@/shared/services/presignedAxios";
import useSettings from "@/record-management/components/hooks/useSettings";
import { OrganizationsPositions } from "@/record-management/services/api/departmentService";
import { MultiSelect } from "@/shared/common/ui/multi-select";
import { useUnitConfiguration } from "@/shared/hooks/useUnitConfiguration";
import { KeyValue } from "@/record-management/types/recordSelectTypes";
import { FormSelectField } from "@/shared/common/form/fields/FormFields";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { useLocalizedName } from "@/shared/common/localizedName";
interface Props {
unitId: string;
}
const HeaderAndFooter = ({ unitId }: Props) => {
const [headers, setHeaders] = useState<HeaderFooterResponseDto[]>([]);
const [footers, setFooters] = useState<HeaderFooterResponseDto[]>([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState("");
const [newFile, setNewFile] = useState<File | null>(null);
const [newType, setNewType] = useState<"header" | "footer">("header");
const [newPreview, setNewPreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const queryClient = useQueryClient();
const lang = i18n.language;
const [selectedPositions, setSelectedPositions] = useState<string[]>([]);
const { data: unitConfigData } = useUnitConfiguration(unitId ?? "", {
enabled: !!unitId,
});
const unitConfig = unitConfigData?.data?.items?.[0];
const canCreateDirectRecord = unitConfig?.canCreateDirectRecord ?? false;
const localizedName = useLocalizedName();
const [selectedRecordType, setSelectedRecordType] = useState<string | null>(
null,
);
const isDirectRecordType = (item: {
name?: { en?: string; am?: string };
key?: string;
}) => {
const key = String(item.key || "").toLowerCase();
const rawName = [item.name?.en, item.name?.am]
.filter(Boolean)
.join(" ")
.toLowerCase();
return key.includes("direct") || rawName.includes("direct");
};
const { recordTypes } = useSettings();
const { allDepratments } = useSettings();
const localizedFormName = useCallback(
(name?: { am: string; en: string }) => {
if (!name) return "";
return lang === "am" ? name.am || name.en : name.en || name.am;
},
[lang],
);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [headerListRes, footerListRes] = await Promise.all([
headerFooterService.getHeadersByUnitId(unitId),
headerFooterService.getFootersByUnitId(unitId),
]);
const activeUploadedHeaders = headerListRes.data.items.filter(
(item) => item.isCurrent && item.uploadedSuccessfully,
);
const activeUploadedFooters = footerListRes.data.items.filter(
(item) => item.isCurrent && item.uploadedSuccessfully,
);
const withPresigned = async (
items: HeaderFooterResponseDto[],
type: "header" | "footer",
) =>
Promise.all(
items.map(async (item) => {
try {
const detail =
type === "header"
? await headerFooterService.getHeaderById(item.id)
: await headerFooterService.getFooterById(item.id);
return { ...item, presigned: detail.data.presigned };
} catch {
return item;
}
}),
);
const [activeHeaders, activeFooters] = await Promise.all([
withPresigned(activeUploadedHeaders, "header"),
withPresigned(activeUploadedFooters, "footer"),
]);
setHeaders(activeHeaders);
setFooters(activeFooters);
} catch {
toast.error(t("contentManagement.failedToLoadHeaderFooter"));
} finally {
setLoading(false);
}
}, [unitId]);
const handleStatusChange = async (
id: string,
type: "header" | "footer",
isCurrent: boolean,
) => {
try {
const payload: HeaderFooterChangeStatusPayload = { isCurrent };
if (type === "header") {
await headerFooterService.changeHeaderStatus(id, payload);
} else {
await headerFooterService.changeFooterStatus(id, payload);
}
// Invalidate the query cache so record forms immediately reflect the changes
queryClient.invalidateQueries({
queryKey: ["headers-footers", unitId],
});
toast.success(t("contentManagement.statusChanged"));
fetchData();
} catch {
toast.error(t("contentManagement.statusChangeFailed"));
}
};
const handleUpload = async () => {
if (!newName || !newFile) {
setError(t("contentManagement.provideMsg"));
return;
}
setUploading(true);
setError(null);
try {
const created = await (newType === "header"
? headerFooterService.uploadAndCreateHeader(
newFile,
newName,
unitId,
selectedPositions,
selectedRecordType,
)
: headerFooterService.uploadAndCreateFooter(
newFile,
newName,
unitId,
selectedPositions,
selectedRecordType,
));
// Make the newly uploaded resource active immediately.
if (created?.id) {
if (newType === "header") {
await headerFooterService.changeHeaderStatus(created.id, {
isCurrent: true,
});
} else {
await headerFooterService.changeFooterStatus(created.id, {
isCurrent: true,
});
}
}
toast.success(`${newType} ${t("contentManagement.uploadSuccess")}`);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
setNewFile(null);
setNewName("");
setNewPreview(null);
setError(null);
setSelectedPositions([]);
setSelectedRecordType(null);
fetchData();
} catch (err) {
console.error(err);
setError(t("contentManagement.uploadFailed"));
toast.error(t("contentManagement.uploadFailed"));
} finally {
setUploading(false);
}
};
useEffect(() => {
fetchData();
}, [fetchData]);
const recordTypeOptions: KeyValue[] =
recordTypes
?.filter((item: { name?: { en?: string; am?: string }; key?: string }) =>
canCreateDirectRecord ? true : !isDirectRecordType(item),
)
.map((item: { name: { en: string; am: string }; key: string }) => ({
label:
item.key == "direct"
? localizedName({ en: "Direct Letter", am: "ቀጥታ ደብዳቤ" })
: localizedName(item.name),
value: item.key,
})) || [];
const renderList = (
title: string,
items: HeaderFooterResponseDto[],
type: "header" | "footer",
) => (
<Card className="p-6 border shadow-sm dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-center justify-between mb-6">
<h2 className="font-semibold text-xl dark:text-white">{title}</h2>
<span className="text-sm text-muted-foreground dark:text-gray-400">
{items.length} {items.length === 1 ? "item" : "items"}
</span>
</div>
{loading ? (
<Skeleton className="h-40" />
) : items.length === 0 ? (
<div className="text-center py-8">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
<span className="text-2xl text-gray-400 dark:text-gray-500">
{type === "header" ? "📄" : "📋"}
</span>
</div>
<p className="text-sm text-muted-foreground dark:text-gray-400 mb-2">
No {title.toLowerCase()} found
</p>
<p className="text-xs text-muted-foreground dark:text-gray-500">
Upload your first {type} using the form below
</p>
</div>
) : (
<div className="space-y-4">
{items.map((item) => (
<div
key={item.id}
className={`border rounded-lg p-4 ${
item.isCurrent
? "bg-primary-50 dark:bg-primary-900/30 border-primary-200 dark:border-primary-800 shadow-sm"
: "bg-white dark:bg-gray-700"
}`}>
{/* Header with name and status */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-lg truncate dark:text-white">
{lang === "en" ? item.name.en : item.name.am}
</h3>
{item.isCurrent && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 dark:bg-primary-900/50 text-primary-800 dark:text-primary-300 flex-shrink-0">
{t("contentManagement.currentlyActive")}
</span>
)}
</div>
<p className="text-sm text-muted-foreground dark:text-gray-400 truncate">
{item.fileInfo.fileName}
</p>
</div>
</div>
{/* Preview and Actions */}
<div className="flex items-center justify-between gap-4">
{/* Preview Image */}
{item.presigned && (
<div className="flex-shrink-0">
<div className="w-24 h-16 border rounded-md overflow-hidden bg-gray-50 dark:bg-gray-600">
<img
src={item.presigned}
alt={`${type} Preview`}
className="w-full h-full object-contain"
/>
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 ml-auto">
<Button
size="sm"
variant="outline"
onClick={() => handleStatusChange(item.id, type, false)}
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 whitespace-nowrap">
{t("contentManagement.delete")}
</Button>
</div>
</div>
</div>
))}
</div>
)}
</Card>
);
return (
<div className="space-y-8">
<div className="grid lg:grid-cols-2 gap-8">
{renderList("Headers", headers, "header")}
{renderList("Footers", footers, "footer")}
</div>
<Card className="p-4 border shadow-sm space-y-4 dark:border-gray-700 dark:bg-gray-800">
<h2 className="font-semibold text-lg flex items-center gap-2 dark:text-white">
<Plus className="h-5 w-5" /> {t("contentManagement.addHeaderFooter")}
</h2>
<div className="grid md:grid-cols-3 gap-4">
<div>
<Label className="dark:text-gray-200">
{t("contentManagement.name")}
</Label>
<Input
value={newName}
onChange={(e) => {
setNewName(e.target.value);
setError(null);
}}
placeholder={t("contentManagement.name")}
disabled={uploading}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<Label className="dark:text-gray-200">
{t("contentManagement.type")}
</Label>
<select
value={newType}
onChange={(e) =>
setNewType(e.target.value as "header" | "footer")
}
className="w-full rounded border px-3 py-2 text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
disabled={uploading}>
<option value="header">{t("contentManagement.header")}</option>
<option value="footer">{t("contentManagement.footer")}</option>
</select>
</div>
<div>
<Label className="dark:text-gray-200">
{t("contentManagement.uploadFile")}
</Label>
<Input
type="file"
accept="image/*"
ref={fileInputRef}
onChange={(e) => {
const file = e.target.files?.[0] || null;
if (file) {
const maxSizeMB = 5; // for example, 2 MB
if (file.size > maxSizeMB * 1024 * 1024) {
setError(`File size must be less than ${maxSizeMB} MB`);
if (fileInputRef.current) fileInputRef.current.value = "";
setNewFile(null);
setNewPreview(null);
return;
}
setNewFile(file);
setNewPreview(URL.createObjectURL(file));
} else {
setNewFile(null);
setNewPreview(null);
}
setError(null);
}}
disabled={uploading}
/>
</div>
<div>
<Label className="dark:text-gray-200">
{t("contentManagement.positions")}
</Label>
<MultiSelect
options={
(allDepratments as OrganizationsPositions[] | undefined)?.map(
(dept) => ({
label: localizedFormName(dept.name),
value: dept.id,
}),
) ?? []
}
value={selectedPositions}
onValueChange={setSelectedPositions}
placeholder={t("contentManagement.selectPositions")}
maxCount={5}
className="w-full max-w-full sm:max-w-md overflow-x-auto"
animation={0}
/>
</div>
<div>
<Label className="dark:text-gray-200">
{t("addRecord.Record Type")}
</Label>
<Select
value={selectedRecordType ?? ""}
onValueChange={(val) => setSelectedRecordType(val || null)}>
<SelectTrigger>
<SelectValue placeholder={t("addRecord.Select Record Type")} />
</SelectTrigger>
<SelectContent>
{recordTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{newPreview && (
<div className="mt-2">
<Label className="dark:text-gray-200">
{t("contentManagement.preview")}
</Label>
<div className="border rounded p-2 inline-block mt-1 dark:border-gray-600">
<img
src={newPreview}
alt="Preview"
className="h-24 object-contain rounded"
/>
</div>
</div>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<Button
onClick={handleUpload}
disabled={uploading}
className="bg-primary hover:bg-primary/90 text-primary-foreground w-full">
{uploading
? t("PDF.uploading")
: `${t("signatureUpload.upload")} ${newType}`}
</Button>
</Card>
</div>
);
};
export default HeaderAndFooter;

View File

@@ -1,377 +1,377 @@
import { Controller, useForm } from "react-hook-form";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import {
LetterTemplate,
LetterTemplatePayload,
} from "@/user-management/services/api/letterTemplateService";
import { TemplateEditor } from "@/super-admin/components/templates/components/TemplateEditor";
import { useHeaderFooterPresigned } from "@/record-management/components/hooks/useHeaderFooterPresigned";
import { useMemo, useState, useEffect } from "react";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/common/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { Card, CardContent } from "@/shared/common/ui/card";
import { useTranslation } from "react-i18next";
import { useLocalizedName } from "@/shared/common/localizedName";
interface Props {
unitId: string;
template?: LetterTemplate | null;
isSubmitting: boolean;
onCancel: () => void;
onSubmitCreate: (data: LetterTemplatePayload) => void;
}
export const LetterTemplateForm = ({
unitId,
template,
isSubmitting,
onCancel,
onSubmitCreate,
}: Props) => {
const { t } = useTranslation();
const localizedName = useLocalizedName();
const [selectedHeader, setSelectedHeader] = useState<string>("");
const [selectedFooter, setSelectedFooter] = useState<string>("");
const {
register,
handleSubmit,
reset,
control,
setValue,
watch,
formState: { errors },
} = useForm<LetterTemplatePayload>({
defaultValues: {
name: {
en: template?.name?.en ?? "",
am: template?.name?.am ?? "",
},
key: template?.key ?? "",
subject: template?.subject ?? "",
body: template?.body ?? "",
sincerelyText: template?.sincerelyText ?? "",
headerId: template?.headerId ?? "",
footerId: template?.footerId ?? "",
unitId,
},
});
const { data: headerFooterData } = useHeaderFooterPresigned(unitId);
const headers = useMemo(
() => headerFooterData?.headers || [],
[headerFooterData?.headers],
);
const footers = useMemo(
() => headerFooterData?.footers || [],
[headerFooterData?.footers],
);
const selectedHeaderId = watch("headerId");
const selectedFooterId = watch("footerId");
// Update header preview when selection changes
useEffect(() => {
if (headers.length && selectedHeaderId) {
const header = headers.find((h) => h.id === selectedHeaderId);
if (header) {
setSelectedHeader(
`<div class="letter-header"><img src="${header.presigned}" alt="Header" style="width: 100%; max-height: 150px;" /></div>`,
);
}
} else {
setSelectedHeader("");
}
}, [headers, selectedHeaderId]);
// Update footer preview when selection changes
useEffect(() => {
if (footers.length && selectedFooterId) {
const footer = footers.find((f) => f.id === selectedFooterId);
if (footer) {
setSelectedFooter(
`<div class="letter-footer"><img src="${footer.presigned}" alt="Footer" style="width: 100%; max-height: 150px;" /></div>`,
);
}
} else {
setSelectedFooter("");
}
}, [footers, selectedFooterId]);
const onSubmit = (values: LetterTemplatePayload) => {
onSubmitCreate(values);
if (!template) {
reset();
}
};
// Header options for select
const headerOptions = headers.map((header) => ({
label: localizedName(header.name),
value: header.id,
}));
// Footer options for select
const footerOptions = footers.map((footer) => ({
label: localizedName(footer.name),
value: footer.id,
}));
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg p-8 shadow-sm 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">
{t("template.key", "Template Key")}
</label>
<Input
placeholder={t("template.keyPlaceholder", "Template Key")}
{...register("key", {
required: t("template.keyRequired", "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">
{t("template.subject", "Subject")}
</label>
<Input
placeholder={t("template.subjectPlaceholder", "Subject")}
{...register("subject", {
required: t("template.subjectRequired", "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">
{t("template.englishName", "English Name")}
</label>
<Input
placeholder={t("template.englishNamePlaceholder", "English Name")}
{...register("name.en", {
required: t(
"template.englishNameRequired",
"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">
{t("template.amharicName", "Amharic Name")}
</label>
<Input
placeholder={t("template.amharicNamePlaceholder", "Amharic Name")}
{...register("name.am", {
required: t(
"template.amharicNameRequired",
"Amharic name is required",
),
})}
/>
{errors.name?.am && (
<p className="text-red-500 text-sm mt-1">
{errors.name.am.message}
</p>
)}
</div>
{/* Header Selection */}
{headerOptions.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("template.header", "Header")}
</label>
<select
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={selectedHeaderId || ""}
onChange={(e) => setValue("headerId", e.target.value)}
>
<option value="">{t("template.noHeader", "No Header")}</option>
{headerOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)}
{/* Footer Selection */}
{footerOptions.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("template.footer", "Footer")}
</label>
<select
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={selectedFooterId || ""}
onChange={(e) => setValue("footerId", e.target.value)}
>
<option value="">{t("template.noFooter", "No Footer")}</option>
{footerOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)}
</div>
{/* Header Preview */}
{selectedHeader && (
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<p className="text-sm font-medium text-muted-foreground mb-2">
{t("template.headerPreview", "Header Preview")}
</p>
<div
className="bg-muted rounded-lg p-4 border-2 border-dashed border-muted-foreground/20"
dangerouslySetInnerHTML={{ __html: selectedHeader }}
/>
</CardContent>
</Card>
)}
{/* Body Section */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("template.body", "Body")}
<span className="text-destructive ml-1">*</span>
</label>
<Controller
name="body"
control={control}
rules={{ required: t("template.bodyRequired", "Body is required") }}
render={({ field, fieldState }) => (
<>
<TemplateEditor
value={field.value}
onEditorChange={field.onChange}
placeholders={[
{
key: "delegatorName",
label: t("placeholder.delegatorName", "Delegator Name"),
},
{
key: "delegatorDepartment",
label: t(
"placeholder.delegatorDepartment",
"Delegator Department",
),
},
{
key: "delegateeName",
label: t("placeholder.delegateeName", "Delegatee Name"),
},
{
key: "delegateeDepartment",
label: t(
"placeholder.delegateeDepartment",
"Delegatee Department",
),
},
{
key: "startDate",
label: t("placeholder.startDate", "Start Date"),
},
{
key: "endDate",
label: t("placeholder.endDate", "End Date"),
},
{
key: "startDateTime",
label: t("placeholder.startDateTime", "Start Date & Time"),
},
{
key: "endDateTime",
label: t("placeholder.endDateTime", "End Date & Time"),
},
]}
/>
{fieldState.invalid && (
<p className="text-red-500 text-sm mt-1">
{fieldState.error?.message}
</p>
)}
</>
)}
/>
</div>
{/* Footer Preview */}
{selectedFooter && (
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<p className="text-sm font-medium text-muted-foreground mb-2">
{t("template.footerPreview", "Footer Preview")}
</p>
<div
className="bg-muted rounded-lg p-4 border-2 border-dashed border-muted-foreground/20"
dangerouslySetInnerHTML={{ __html: selectedFooter }}
/>
</CardContent>
</Card>
)}
{/* Sincerely Text */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("template.sincerelyText", "Sincerely Text")}
</label>
<Input
placeholder={t("template.sincerelyTextPlaceholder", "Sincerely Text")}
{...register("sincerelyText")}
/>
</div>
{/* Action Buttons */}
<div className="flex gap-2 pt-4 justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? t("common.saving", "Saving...")
: template
? t("common.update", "Update")
: t("common.save", "Save")}
</Button>
<Button type="button" variant="outline" onClick={onCancel}>
{t("common.cancel", "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 {
LetterTemplate,
LetterTemplatePayload,
} from "@/user-management/services/api/letterTemplateService";
import { TemplateEditor } from "@/super-admin/components/templates/components/TemplateEditor";
import { useHeaderFooterPresigned } from "@/record-management/components/hooks/useHeaderFooterPresigned";
import { useMemo, useState, useEffect } from "react";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/common/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { Card, CardContent } from "@/shared/common/ui/card";
import { useTranslation } from "react-i18next";
import { useLocalizedName } from "@/shared/common/localizedName";
interface Props {
unitId: string;
template?: LetterTemplate | null;
isSubmitting: boolean;
onCancel: () => void;
onSubmitCreate: (data: LetterTemplatePayload) => void;
}
export const LetterTemplateForm = ({
unitId,
template,
isSubmitting,
onCancel,
onSubmitCreate,
}: Props) => {
const { t } = useTranslation();
const localizedName = useLocalizedName();
const [selectedHeader, setSelectedHeader] = useState<string>("");
const [selectedFooter, setSelectedFooter] = useState<string>("");
const {
register,
handleSubmit,
reset,
control,
setValue,
watch,
formState: { errors },
} = useForm<LetterTemplatePayload>({
defaultValues: {
name: {
en: template?.name?.en ?? "",
am: template?.name?.am ?? "",
},
key: template?.key ?? "",
subject: template?.subject ?? "",
body: template?.body ?? "",
sincerelyText: template?.sincerelyText ?? "",
headerId: template?.headerId ?? "",
footerId: template?.footerId ?? "",
unitId,
},
});
const { data: headerFooterData } = useHeaderFooterPresigned(unitId);
const headers = useMemo(
() => headerFooterData?.headers || [],
[headerFooterData?.headers],
);
const footers = useMemo(
() => headerFooterData?.footers || [],
[headerFooterData?.footers],
);
const selectedHeaderId = watch("headerId");
const selectedFooterId = watch("footerId");
// Update header preview when selection changes
useEffect(() => {
if (headers.length && selectedHeaderId) {
const header = headers.find((h) => h.id === selectedHeaderId);
if (header) {
setSelectedHeader(
`<div class="letter-header"><img src="${header.presigned}" alt="Header" style="width: 100%; max-height: 150px;" /></div>`,
);
}
} else {
setSelectedHeader("");
}
}, [headers, selectedHeaderId]);
// Update footer preview when selection changes
useEffect(() => {
if (footers.length && selectedFooterId) {
const footer = footers.find((f) => f.id === selectedFooterId);
if (footer) {
setSelectedFooter(
`<div class="letter-footer"><img src="${footer.presigned}" alt="Footer" style="width: 100%; max-height: 150px;" /></div>`,
);
}
} else {
setSelectedFooter("");
}
}, [footers, selectedFooterId]);
const onSubmit = (values: LetterTemplatePayload) => {
onSubmitCreate(values);
if (!template) {
reset();
}
};
// Header options for select
const headerOptions = headers.map((header) => ({
label: localizedName(header.name),
value: header.id,
}));
// Footer options for select
const footerOptions = footers.map((footer) => ({
label: localizedName(footer.name),
value: footer.id,
}));
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg p-8 shadow-sm 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">
{t("template.key", "Template Key")}
</label>
<Input
placeholder={t("template.keyPlaceholder", "Template Key")}
{...register("key", {
required: t("template.keyRequired", "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">
{t("template.subject", "Subject")}
</label>
<Input
placeholder={t("template.subjectPlaceholder", "Subject")}
{...register("subject", {
required: t("template.subjectRequired", "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">
{t("template.englishName", "English Name")}
</label>
<Input
placeholder={t("template.englishNamePlaceholder", "English Name")}
{...register("name.en", {
required: t(
"template.englishNameRequired",
"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">
{t("template.amharicName", "Amharic Name")}
</label>
<Input
placeholder={t("template.amharicNamePlaceholder", "Amharic Name")}
{...register("name.am", {
required: t(
"template.amharicNameRequired",
"Amharic name is required",
),
})}
/>
{errors.name?.am && (
<p className="text-red-500 text-sm mt-1">
{errors.name.am.message}
</p>
)}
</div>
{/* Header Selection */}
{headerOptions.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("template.header", "Header")}
</label>
<select
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={selectedHeaderId || ""}
onChange={(e) => setValue("headerId", e.target.value)}
>
<option value="">{t("template.noHeader", "No Header")}</option>
{headerOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)}
{/* Footer Selection */}
{footerOptions.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("template.footer", "Footer")}
</label>
<select
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
value={selectedFooterId || ""}
onChange={(e) => setValue("footerId", e.target.value)}
>
<option value="">{t("template.noFooter", "No Footer")}</option>
{footerOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
)}
</div>
{/* Header Preview */}
{selectedHeader && (
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<p className="text-sm font-medium text-muted-foreground mb-2">
{t("template.headerPreview", "Header Preview")}
</p>
<div
className="bg-muted rounded-lg p-4 border-2 border-dashed border-muted-foreground/20"
dangerouslySetInnerHTML={{ __html: selectedHeader }}
/>
</CardContent>
</Card>
)}
{/* Body Section */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("template.body", "Body")}
<span className="text-destructive ml-1">*</span>
</label>
<Controller
name="body"
control={control}
rules={{ required: t("template.bodyRequired", "Body is required") }}
render={({ field, fieldState }) => (
<>
<TemplateEditor
value={field.value}
onEditorChange={field.onChange}
placeholders={[
{
key: "delegatorName",
label: t("placeholder.delegatorName", "Delegator Name"),
},
{
key: "delegatorDepartment",
label: t(
"placeholder.delegatorDepartment",
"Delegator Department",
),
},
{
key: "delegateeName",
label: t("placeholder.delegateeName", "Delegatee Name"),
},
{
key: "delegateeDepartment",
label: t(
"placeholder.delegateeDepartment",
"Delegatee Department",
),
},
{
key: "startDate",
label: t("placeholder.startDate", "Start Date"),
},
{
key: "endDate",
label: t("placeholder.endDate", "End Date"),
},
{
key: "startDateTime",
label: t("placeholder.startDateTime", "Start Date & Time"),
},
{
key: "endDateTime",
label: t("placeholder.endDateTime", "End Date & Time"),
},
]}
/>
{fieldState.invalid && (
<p className="text-red-500 text-sm mt-1">
{fieldState.error?.message}
</p>
)}
</>
)}
/>
</div>
{/* Footer Preview */}
{selectedFooter && (
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<p className="text-sm font-medium text-muted-foreground mb-2">
{t("template.footerPreview", "Footer Preview")}
</p>
<div
className="bg-muted rounded-lg p-4 border-2 border-dashed border-muted-foreground/20"
dangerouslySetInnerHTML={{ __html: selectedFooter }}
/>
</CardContent>
</Card>
)}
{/* Sincerely Text */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("template.sincerelyText", "Sincerely Text")}
</label>
<Input
placeholder={t("template.sincerelyTextPlaceholder", "Sincerely Text")}
{...register("sincerelyText")}
/>
</div>
{/* Action Buttons */}
<div className="flex gap-2 pt-4 justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? t("common.saving", "Saving...")
: template
? t("common.update", "Update")
: t("common.save", "Save")}
</Button>
<Button type="button" variant="outline" onClick={onCancel}>
{t("common.cancel", "Cancel")}
</Button>
</div>
</form>
);
};

View File

@@ -1,154 +1,154 @@
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 { useLetterTemplates } from "@/user-management/hooks/useLetterTemplates";
import { LetterTemplateForm } from "./LetterTemplateForm";
import { toast } from "sonner";
import { Button } from "@/shared/common/ui/button";
import { t } from "i18next";
import i18n from "@/i18n";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
interface Props {
unitId: string;
}
const LetterTemplatesCard = ({ unitId }: Props) => {
const { handleError } = useErrorHandler(t);
const {
letterTemplatesResponse,
isLoading,
isError,
refetch,
createLetterTemplate,
updateLetterTemplate,
isCreating,
} = useLetterTemplates(unitId);
const lang = i18n.language;
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
null
);
const [showCreateForm, setShowCreateForm] = useState(false);
const selectedTemplate =
letterTemplatesResponse?.items?.find((t) => t.id === selectedTemplateId) ??
null;
return (
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-900 dark:border-gray-700">
<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 className="dark:text-gray-400">
{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>
) : (
<>
{letterTemplatesResponse && letterTemplatesResponse?.count > 0 && (
<Select
onValueChange={(value) => {
setSelectedTemplateId(value);
setShowCreateForm(false);
}}
value={selectedTemplateId ?? undefined}
>
<SelectTrigger>
<SelectValue placeholder={t("contentManagement.selectTemplate")} />
</SelectTrigger>
<SelectContent>
{letterTemplatesResponse.items.map((template) => (
<SelectItem key={template.id} value={template.id}>
{lang === "en" ? template.name.en : template.name.am}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{(selectedTemplate || showCreateForm) && (
<LetterTemplateForm
unitId={unitId}
isSubmitting={isCreating}
template={selectedTemplate}
onCancel={() => {
setShowCreateForm(false);
setSelectedTemplateId(null);
}}
onSubmitCreate={(values) => {
if (selectedTemplate) {
updateLetterTemplate(
{ id: selectedTemplate.id, data: values },
{
onSuccess: () => {
toast.success(t("contentManagement.updateTemplate"));
refetch();
setShowCreateForm(false);
},
onError: (error) => {
handleError(error);
},
}
);
} else {
createLetterTemplate(values, {
onSuccess: (newTemplate) => {
toast.success(
`${newTemplate.name.en} ${t("contentManagement.templateSuccessMsg")}`
);
refetch();
setShowCreateForm(false);
setSelectedTemplateId(newTemplate.id);
},
onError: (error) => {
handleError(error);
},
});
}
}}
/>
)}
</>
)}
</CardContent>
</Card>
);
};
export default LetterTemplatesCard;
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 { useLetterTemplates } from "@/user-management/hooks/useLetterTemplates";
import { LetterTemplateForm } from "./LetterTemplateForm";
import { toast } from "sonner";
import { Button } from "@/shared/common/ui/button";
import { t } from "i18next";
import i18n from "@/i18n";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
interface Props {
unitId: string;
}
const LetterTemplatesCard = ({ unitId }: Props) => {
const { handleError } = useErrorHandler(t);
const {
letterTemplatesResponse,
isLoading,
isError,
refetch,
createLetterTemplate,
updateLetterTemplate,
isCreating,
} = useLetterTemplates(unitId);
const lang = i18n.language;
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
null
);
const [showCreateForm, setShowCreateForm] = useState(false);
const selectedTemplate =
letterTemplatesResponse?.items?.find((t) => t.id === selectedTemplateId) ??
null;
return (
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-900 dark:border-gray-700">
<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 className="dark:text-gray-400">
{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>
) : (
<>
{letterTemplatesResponse && letterTemplatesResponse?.count > 0 && (
<Select
onValueChange={(value) => {
setSelectedTemplateId(value);
setShowCreateForm(false);
}}
value={selectedTemplateId ?? undefined}
>
<SelectTrigger>
<SelectValue placeholder={t("contentManagement.selectTemplate")} />
</SelectTrigger>
<SelectContent>
{letterTemplatesResponse.items.map((template) => (
<SelectItem key={template.id} value={template.id}>
{lang === "en" ? template.name.en : template.name.am}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{(selectedTemplate || showCreateForm) && (
<LetterTemplateForm
unitId={unitId}
isSubmitting={isCreating}
template={selectedTemplate}
onCancel={() => {
setShowCreateForm(false);
setSelectedTemplateId(null);
}}
onSubmitCreate={(values) => {
if (selectedTemplate) {
updateLetterTemplate(
{ id: selectedTemplate.id, data: values },
{
onSuccess: () => {
toast.success(t("contentManagement.updateTemplate"));
refetch();
setShowCreateForm(false);
},
onError: (error) => {
handleError(error);
},
}
);
} else {
createLetterTemplate(values, {
onSuccess: (newTemplate) => {
toast.success(
`${newTemplate.name.en} ${t("contentManagement.templateSuccessMsg")}`
);
refetch();
setShowCreateForm(false);
setSelectedTemplateId(newTemplate.id);
},
onError: (error) => {
handleError(error);
},
});
}
}}
/>
)}
</>
)}
</CardContent>
</Card>
);
};
export default LetterTemplatesCard;

View File

@@ -1,409 +1,409 @@
// PrefixModal.tsx
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@radix-ui/react-label";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
CreatePrefixPayload,
CreateSuffixPayload,
prefixSuffixService,
} from "@/user-management/services/api/prefixSuffixService";
import { t } from "i18next";
import { PrefixTabType } from "./PrefixAndSuffixCard";
import { toast } from "sonner";
import {
useAddReferenceNumber,
useGetReferenceNumbers,
useUpdateExternalReferencePrefix,
useUpdateInternalMemoReferencePrefix,
} from "@/shared/hooks/useReferenceNumberPrefixes";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { SingleSelect } from "@/shared/common/ui/single-select";
interface PrefixModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
unitId: string;
cardType: string; // e.g., "prefix", "suffix", "prefixCC", "reference", "internalPrefix", etc.
recordType: PrefixTabType;
editingItem?: any;
positions?: any[];
}
export const PrefixModal = ({
open,
onClose,
onSuccess,
unitId,
cardType,
recordType,
editingItem,
positions = [],
}: PrefixModalProps) => {
const queryClient = useQueryClient();
const isEditing = !!editingItem;
// Form state
const [nameAm, setNameAm] = useState("");
const [nameEn, setNameEn] = useState("");
const [positionId, setPositionId] = useState("");
// Determine if this is a reference type
const isReference = [
"reference",
"externalReference",
"internalMemoReference",
].includes(cardType);
const isPositionPrefix = recordType === "positionPrefix";
const isCC = cardType.includes("CC");
const isReferenceCard = cardType === "reference";
const isExternalReferenceCard = cardType === "externalReference";
const isInternalMemoReferenceCard = cardType === "internalMemoReference";
const positionOptions = positions
.filter((pos) => Boolean(pos?.id))
.map((pos) => ({
value: pos.id,
label: pos.name?.en || pos.name?.am || pos.id,
}));
const positionRecordTypeKey =
cardType === "internalPrefix"
? "internal"
: cardType === "externalPrefix"
? "external"
: cardType === "internalMemoPrefix"
? "internal_memo"
: editingItem?.recordTypeKey;
const { data: referenceNumbersData } = useGetReferenceNumbers(
isReference ? unitId : "",
);
const addReferenceNumberMutation = useAddReferenceNumber();
const updateInternalMemoReferenceMutation =
useUpdateInternalMemoReferencePrefix();
const updateExternalReferenceMutation = useUpdateExternalReferencePrefix();
const { handleError } = useErrorHandler(t);
const referenceNumbers =
((referenceNumbersData?.data as any)?.items?.[0] as any) ||
referenceNumbersData?.data ||
{};
const isArray = Array.isArray(referenceNumbers);
const referenceNumberPrefix =
isArray &&
referenceNumbers?.find((rn: any) => rn.name === "referenceNumberPrefix");
const externalReferenceNumberPrefix =
isArray &&
referenceNumbers?.find(
(rn: any) => rn.name === "externalReferenceNumberPrefix",
);
const internalMemoReferenceNumberPrefix =
isArray &&
referenceNumbers?.find(
(rn: any) => rn.name === "internalMemoReferenceNumberPrefix",
);
// Populate form when editing
useEffect(() => {
if (editingItem) {
if (isReference) {
setNameAm(editingItem.name?.am || "");
setNameEn(editingItem.name?.en || "");
} else {
setNameAm(editingItem.name?.am || "");
setNameEn(editingItem.name?.en || "");
}
setPositionId(editingItem.positionId || "");
} else {
// Reset
setNameAm("");
setNameEn("");
setPositionId("");
}
}, [editingItem, isReference]);
useEffect(() => {
if (!isReference || editingItem) return;
if (isReferenceCard) {
setNameAm(referenceNumbers.referenceNumberPrefix || "");
setNameEn(referenceNumbers.referenceNumberPrefix || "");
return;
}
if (isExternalReferenceCard) {
setNameAm(referenceNumbers.externalReferenceNumberPrefix || "");
setNameEn(referenceNumbers.externalReferenceNumberPrefix || "");
return;
}
if (isInternalMemoReferenceCard) {
setNameAm(referenceNumbers.internalMemoReferenceNumberPrefix || "");
setNameEn(referenceNumbers.internalMemoReferenceNumberPrefix || "");
}
}, [
editingItem,
isReference,
isReferenceCard,
isExternalReferenceCard,
isInternalMemoReferenceCard,
referenceNumbers.referenceNumberPrefix,
referenceNumbers.externalReferenceNumberPrefix,
referenceNumbers.internalMemoReferenceNumberPrefix,
]);
// Mutation for saving
const { mutate: save, isPending } = useMutation({
mutationFn: async () => {
const baseRecordTypeKey = recordType;
const isPrefixOperation =
isReference ||
isPositionPrefix ||
cardType === "prefix" ||
cardType === "prefixCC" ||
cardType === "internalPrefix" ||
cardType === "externalPrefix" ||
cardType === "internalMemoPrefix";
let prefixPayload: CreatePrefixPayload | undefined;
let suffixPayload: CreateSuffixPayload | undefined;
if (isReference) {
if (isReferenceCard) {
await addReferenceNumberMutation.mutateAsync({
unitId,
payload: {
referenceNumberPrefix: {
am: nameAm,
en: nameEn,
},
},
});
return;
}
if (isExternalReferenceCard) {
await updateExternalReferenceMutation.mutateAsync({
unitId,
payload: {
externalReferenceNumberPrefix: {
am: nameAm,
en: nameEn,
},
},
});
return;
}
if (isInternalMemoReferenceCard) {
await updateInternalMemoReferenceMutation.mutateAsync({
unitId,
payload: {
internalMemoReferenceNumberPrefix: {
am: nameAm,
en: nameEn,
},
},
});
return;
}
} else if (isPositionPrefix) {
if (
positionRecordTypeKey !== "internal" &&
positionRecordTypeKey !== "external" &&
positionRecordTypeKey !== "internal_memo"
) {
throw new Error(
"Position prefix requires internal, external, or internal_memo recordTypeKey",
);
}
prefixPayload = {
unitId,
recordTypeKey: positionRecordTypeKey,
isForCC: false,
positionId,
name: { am: nameAm, en: nameEn },
};
} else {
const commonPayload = {
unitId,
recordTypeKey: baseRecordTypeKey,
isForCC: isCC,
name: { am: nameAm, en: nameEn },
};
if (cardType === "suffix" || cardType === "suffixCC") {
suffixPayload = commonPayload;
} else {
prefixPayload = commonPayload;
}
}
if (isEditing) {
if (isPrefixOperation && prefixPayload) {
return prefixSuffixService.editPrefix(editingItem.id, prefixPayload);
}
if (suffixPayload) {
return prefixSuffixService.editSuffix(editingItem.id, suffixPayload);
}
throw new Error("Invalid prefix/suffix payload for update");
}
if (isPrefixOperation && prefixPayload) {
return prefixSuffixService.createPrefix(prefixPayload);
}
if (suffixPayload) {
return prefixSuffixService.createSuffix(suffixPayload);
}
throw new Error("Invalid prefix/suffix payload for create");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["prefixSuffix"] });
toast.success(t("msg.successfullyCompleted"));
onSuccess();
},
onError: (error) => {
handleError(error);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
save();
};
const isValid = () => {
if (isReference) return nameAm.trim() !== "" && nameEn.trim() !== "";
if (isPositionPrefix)
return nameAm.trim() !== "" && nameEn.trim() !== "" && positionId;
return nameAm.trim() !== "" && nameEn.trim() !== "";
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px] dark:bg-gray-800">
<DialogHeader>
<DialogTitle className="dark:text-white">
{isEditing
? t("contentManagement.edit")
: t("contentManagement.add")}{" "}
{t(`contentManagement.${cardType}`)}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{isReference ? (
<div className="space-y-2">
<div className="space-y-2">
<Label htmlFor="nameAm" className="dark:text-gray-200">
{t("contentManagement.amharicName")} *
</Label>
<Input
id="nameAm"
value={nameAm}
onChange={(e) => setNameAm(e.target.value)}
placeholder={t("contentManagement.amharicName")}
disabled={isPending}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="nameEn" className="dark:text-gray-200">
{t("contentManagement.englishName")} *
</Label>
<Input
id="nameEn"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder={t("contentManagement.englishName")}
disabled={isPending}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="nameAm" className="dark:text-gray-200">
{t("contentManagement.amharicName")} *
</Label>
<Input
id="nameAm"
value={nameAm}
onChange={(e) => setNameAm(e.target.value)}
placeholder={t("contentManagement.amharicName")}
disabled={isPending}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="nameEn" className="dark:text-gray-200">
{t("contentManagement.englishName")} *
</Label>
<Input
id="nameEn"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder={t("contentManagement.englishName")}
disabled={isPending}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</>
)}
{isPositionPrefix && (
<div className="space-y-2">
<Label htmlFor="position" className="dark:text-gray-200">
{t("contentManagement.position")} *
</Label>
<SingleSelect
options={positionOptions}
value={positionId}
onValueChange={setPositionId}
placeholder={t("contentManagement.selectPosition")}
className={isPending ? "pointer-events-none opacity-60" : ""}
/>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isPending}
>
{t("common.Cancel")}
</Button>
<Button
type="submit"
disabled={!isValid() || isPending}
className="bg-purple-600 hover:bg-purple-700"
>
{isPending
? t("contentManagement.saving")
: (isReferenceCard &&
(referenceNumbers.referenceNumberPrefix ||
referenceNumbers.externalReferenceNumberPrefix ||
referenceNumbers.internalMemoReferenceNumberPrefix)) ||
isEditing
? t("common.update")
: t("common.save")}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
// PrefixModal.tsx
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@radix-ui/react-label";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
CreatePrefixPayload,
CreateSuffixPayload,
prefixSuffixService,
} from "@/user-management/services/api/prefixSuffixService";
import { t } from "i18next";
import { PrefixTabType } from "./PrefixAndSuffixCard";
import { toast } from "sonner";
import {
useAddReferenceNumber,
useGetReferenceNumbers,
useUpdateExternalReferencePrefix,
useUpdateInternalMemoReferencePrefix,
} from "@/shared/hooks/useReferenceNumberPrefixes";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
import { SingleSelect } from "@/shared/common/ui/single-select";
interface PrefixModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
unitId: string;
cardType: string; // e.g., "prefix", "suffix", "prefixCC", "reference", "internalPrefix", etc.
recordType: PrefixTabType;
editingItem?: any;
positions?: any[];
}
export const PrefixModal = ({
open,
onClose,
onSuccess,
unitId,
cardType,
recordType,
editingItem,
positions = [],
}: PrefixModalProps) => {
const queryClient = useQueryClient();
const isEditing = !!editingItem;
// Form state
const [nameAm, setNameAm] = useState("");
const [nameEn, setNameEn] = useState("");
const [positionId, setPositionId] = useState("");
// Determine if this is a reference type
const isReference = [
"reference",
"externalReference",
"internalMemoReference",
].includes(cardType);
const isPositionPrefix = recordType === "positionPrefix";
const isCC = cardType.includes("CC");
const isReferenceCard = cardType === "reference";
const isExternalReferenceCard = cardType === "externalReference";
const isInternalMemoReferenceCard = cardType === "internalMemoReference";
const positionOptions = positions
.filter((pos) => Boolean(pos?.id))
.map((pos) => ({
value: pos.id,
label: pos.name?.en || pos.name?.am || pos.id,
}));
const positionRecordTypeKey =
cardType === "internalPrefix"
? "internal"
: cardType === "externalPrefix"
? "external"
: cardType === "internalMemoPrefix"
? "internal_memo"
: editingItem?.recordTypeKey;
const { data: referenceNumbersData } = useGetReferenceNumbers(
isReference ? unitId : "",
);
const addReferenceNumberMutation = useAddReferenceNumber();
const updateInternalMemoReferenceMutation =
useUpdateInternalMemoReferencePrefix();
const updateExternalReferenceMutation = useUpdateExternalReferencePrefix();
const { handleError } = useErrorHandler(t);
const referenceNumbers =
((referenceNumbersData?.data as any)?.items?.[0] as any) ||
referenceNumbersData?.data ||
{};
const isArray = Array.isArray(referenceNumbers);
const referenceNumberPrefix =
isArray &&
referenceNumbers?.find((rn: any) => rn.name === "referenceNumberPrefix");
const externalReferenceNumberPrefix =
isArray &&
referenceNumbers?.find(
(rn: any) => rn.name === "externalReferenceNumberPrefix",
);
const internalMemoReferenceNumberPrefix =
isArray &&
referenceNumbers?.find(
(rn: any) => rn.name === "internalMemoReferenceNumberPrefix",
);
// Populate form when editing
useEffect(() => {
if (editingItem) {
if (isReference) {
setNameAm(editingItem.name?.am || "");
setNameEn(editingItem.name?.en || "");
} else {
setNameAm(editingItem.name?.am || "");
setNameEn(editingItem.name?.en || "");
}
setPositionId(editingItem.positionId || "");
} else {
// Reset
setNameAm("");
setNameEn("");
setPositionId("");
}
}, [editingItem, isReference]);
useEffect(() => {
if (!isReference || editingItem) return;
if (isReferenceCard) {
setNameAm(referenceNumbers.referenceNumberPrefix || "");
setNameEn(referenceNumbers.referenceNumberPrefix || "");
return;
}
if (isExternalReferenceCard) {
setNameAm(referenceNumbers.externalReferenceNumberPrefix || "");
setNameEn(referenceNumbers.externalReferenceNumberPrefix || "");
return;
}
if (isInternalMemoReferenceCard) {
setNameAm(referenceNumbers.internalMemoReferenceNumberPrefix || "");
setNameEn(referenceNumbers.internalMemoReferenceNumberPrefix || "");
}
}, [
editingItem,
isReference,
isReferenceCard,
isExternalReferenceCard,
isInternalMemoReferenceCard,
referenceNumbers.referenceNumberPrefix,
referenceNumbers.externalReferenceNumberPrefix,
referenceNumbers.internalMemoReferenceNumberPrefix,
]);
// Mutation for saving
const { mutate: save, isPending } = useMutation({
mutationFn: async () => {
const baseRecordTypeKey = recordType;
const isPrefixOperation =
isReference ||
isPositionPrefix ||
cardType === "prefix" ||
cardType === "prefixCC" ||
cardType === "internalPrefix" ||
cardType === "externalPrefix" ||
cardType === "internalMemoPrefix";
let prefixPayload: CreatePrefixPayload | undefined;
let suffixPayload: CreateSuffixPayload | undefined;
if (isReference) {
if (isReferenceCard) {
await addReferenceNumberMutation.mutateAsync({
unitId,
payload: {
referenceNumberPrefix: {
am: nameAm,
en: nameEn,
},
},
});
return;
}
if (isExternalReferenceCard) {
await updateExternalReferenceMutation.mutateAsync({
unitId,
payload: {
externalReferenceNumberPrefix: {
am: nameAm,
en: nameEn,
},
},
});
return;
}
if (isInternalMemoReferenceCard) {
await updateInternalMemoReferenceMutation.mutateAsync({
unitId,
payload: {
internalMemoReferenceNumberPrefix: {
am: nameAm,
en: nameEn,
},
},
});
return;
}
} else if (isPositionPrefix) {
if (
positionRecordTypeKey !== "internal" &&
positionRecordTypeKey !== "external" &&
positionRecordTypeKey !== "internal_memo"
) {
throw new Error(
"Position prefix requires internal, external, or internal_memo recordTypeKey",
);
}
prefixPayload = {
unitId,
recordTypeKey: positionRecordTypeKey,
isForCC: false,
positionId,
name: { am: nameAm, en: nameEn },
};
} else {
const commonPayload = {
unitId,
recordTypeKey: baseRecordTypeKey,
isForCC: isCC,
name: { am: nameAm, en: nameEn },
};
if (cardType === "suffix" || cardType === "suffixCC") {
suffixPayload = commonPayload;
} else {
prefixPayload = commonPayload;
}
}
if (isEditing) {
if (isPrefixOperation && prefixPayload) {
return prefixSuffixService.editPrefix(editingItem.id, prefixPayload);
}
if (suffixPayload) {
return prefixSuffixService.editSuffix(editingItem.id, suffixPayload);
}
throw new Error("Invalid prefix/suffix payload for update");
}
if (isPrefixOperation && prefixPayload) {
return prefixSuffixService.createPrefix(prefixPayload);
}
if (suffixPayload) {
return prefixSuffixService.createSuffix(suffixPayload);
}
throw new Error("Invalid prefix/suffix payload for create");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["prefixSuffix"] });
toast.success(t("msg.successfullyCompleted"));
onSuccess();
},
onError: (error) => {
handleError(error);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
save();
};
const isValid = () => {
if (isReference) return nameAm.trim() !== "" && nameEn.trim() !== "";
if (isPositionPrefix)
return nameAm.trim() !== "" && nameEn.trim() !== "" && positionId;
return nameAm.trim() !== "" && nameEn.trim() !== "";
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px] dark:bg-gray-800">
<DialogHeader>
<DialogTitle className="dark:text-white">
{isEditing
? t("contentManagement.edit")
: t("contentManagement.add")}{" "}
{t(`contentManagement.${cardType}`)}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{isReference ? (
<div className="space-y-2">
<div className="space-y-2">
<Label htmlFor="nameAm" className="dark:text-gray-200">
{t("contentManagement.amharicName")} *
</Label>
<Input
id="nameAm"
value={nameAm}
onChange={(e) => setNameAm(e.target.value)}
placeholder={t("contentManagement.amharicName")}
disabled={isPending}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="nameEn" className="dark:text-gray-200">
{t("contentManagement.englishName")} *
</Label>
<Input
id="nameEn"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder={t("contentManagement.englishName")}
disabled={isPending}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="nameAm" className="dark:text-gray-200">
{t("contentManagement.amharicName")} *
</Label>
<Input
id="nameAm"
value={nameAm}
onChange={(e) => setNameAm(e.target.value)}
placeholder={t("contentManagement.amharicName")}
disabled={isPending}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="nameEn" className="dark:text-gray-200">
{t("contentManagement.englishName")} *
</Label>
<Input
id="nameEn"
value={nameEn}
onChange={(e) => setNameEn(e.target.value)}
placeholder={t("contentManagement.englishName")}
disabled={isPending}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</>
)}
{isPositionPrefix && (
<div className="space-y-2">
<Label htmlFor="position" className="dark:text-gray-200">
{t("contentManagement.position")} *
</Label>
<SingleSelect
options={positionOptions}
value={positionId}
onValueChange={setPositionId}
placeholder={t("contentManagement.selectPosition")}
className={isPending ? "pointer-events-none opacity-60" : ""}
/>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isPending}
>
{t("common.Cancel")}
</Button>
<Button
type="submit"
disabled={!isValid() || isPending}
className="bg-purple-600 hover:bg-purple-700"
>
{isPending
? t("contentManagement.saving")
: (isReferenceCard &&
(referenceNumbers.referenceNumberPrefix ||
referenceNumbers.externalReferenceNumberPrefix ||
referenceNumbers.internalMemoReferenceNumberPrefix)) ||
isEditing
? t("common.update")
: t("common.save")}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,246 +1,246 @@
// PrefixSuffixTable.tsx
import React, { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Pencil, Trash2, X } from "lucide-react";
import { t } from "i18next";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { prefixSuffixService } from "@/user-management/services/api/prefixSuffixService";
import { useQueryClient } from "@tanstack/react-query";
interface PrefixSuffixTableProps {
items: any[];
isLoading: boolean;
onEdit: (item: any) => void;
onDelete: (id: string, type: string, recordType: string) => void;
showPositionColumn?: boolean;
showCountColumn?: boolean;
positions?: any[];
}
export const PrefixSuffixTable = ({
items,
isLoading,
onEdit,
onDelete,
showPositionColumn = false,
showCountColumn = false,
positions = [],
}: PrefixSuffixTableProps) => {
const queryClient = useQueryClient();
const getPositionName = (positionId: string) => {
const pos = positions.find((p) => p.id === positionId);
return pos?.name?.en || pos?.name?.am || positionId;
};
const [countPopup, setCountPopup] = useState<{
itemId: string;
currentCount: number;
} | null>(null);
const [countValue, setCountValue] = useState("");
// 1. Helper to get count from either source
const getCount = (item: any): number | undefined => {
return item.recordSequences?.[0]?.count ?? item.count ?? undefined;
};
// 2. Helper to get sequence ID from either source
const getSequenceId = (item: any): string | undefined => {
return item.recordSequences?.[0]?.id ?? item.sequenceId ?? undefined;
};
// 3. Updated openCountPopup
const openCountPopup = (item: any) => {
const currentCount = getCount(item) ?? 0;
const sequenceId = getSequenceId(item);
if (!sequenceId) {
console.error("No sequence ID found for item:", item);
return;
}
setCountPopup({ itemId: sequenceId, currentCount });
setCountValue(String(currentCount));
};
const closeCountPopup = () => {
setCountPopup(null);
setCountValue("");
queryClient.invalidateQueries({
queryKey: ["prefixes-by-position"],
});
queryClient.invalidateQueries({
queryKey: ["unit-reference-numbers"],
});
};
const handleCountSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!countPopup) return;
const newCount = parseInt(countValue, 10);
if (isNaN(newCount) || newCount < 0) return;
prefixSuffixService.updateCount(countPopup.itemId, newCount).then(() => {
closeCountPopup();
});
};
return (
<div className="overflow-x-auto border rounded-lg dark:border-gray-600">
<table className="min-w-full text-sm text-left">
<thead className="bg-gray-50 dark:bg-gray-700 border-b dark:border-gray-600">
<tr>
<th className="px-4 py-3 dark:text-white">#</th>
<th className="px-4 py-3 dark:text-white">{t("header.amharic")}</th>
<th className="px-4 py-3 dark:text-white">{t("header.english")}</th>
<th className="px-4 py-3 dark:text-white">
{t("contentManagement.type")}
</th>
{showPositionColumn && (
<th className="px-4 py-3 dark:text-white">
{t("contentManagement.position")}
</th>
)}
{showCountColumn && (
<th className="px-4 py-3 dark:text-white">
{t("contentManagement.count")}
</th>
)}
<th className="px-4 py-3 dark:text-white">
{t("userRecord.Actions")}
</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td
colSpan={showPositionColumn ? 6 : 5}
className="text-center py-8 text-gray-500 dark:text-gray-400">
{t("contentManagement.loading")}
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td
colSpan={showPositionColumn ? 6 : 5}
className="text-center py-8 text-gray-500 dark:text-gray-400">
{t("contentManagement.noRec")}
</td>
</tr>
) : (
items.map((item, idx) => {
return (
<tr
key={item.id}
className="border-b hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600">
<td className="px-4 py-3 dark:text-gray-300">{idx + 1}</td>
<td className="px-4 py-3 dark:text-gray-300">
{item.name?.am || "-"}
</td>
<td className="px-4 py-3 dark:text-gray-300">
{item.name?.en || "-"}
</td>
<td className="px-4 py-3 capitalize dark:text-gray-300">
{item.type}
{item.isForCC && (
<span className="ml-1 text-xs text-purple-600 dark:text-purple-400">
(CC)
</span>
)}
</td>
{showPositionColumn && (
<td className="px-4 py-3 dark:text-gray-300">
{item.positionId ? getPositionName(item.positionId) : "-"}
</td>
)}
{showCountColumn && (
<td className="px-4 py-3 dark:text-gray-300">
{(() => {
const count = getCount(item);
if (count === undefined) return "-";
return (
<button
onClick={() => openCountPopup(item)}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 underline cursor-pointer">
{count}
</button>
);
})()}
</td>
)}
<td className="px-4 py-3">
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
onClick={() =>
onDelete(item.id, item.type, item.recordTypeKey)
}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
{/* Count Update Popup */}
{countPopup && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 w-80">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold dark:text-white">
{t("contentManagement.updateCount")}
</h3>
<button
onClick={closeCountPopup}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleCountSubmit}>
<div className="space-y-2 mb-4">
<Label htmlFor="countValue" className="dark:text-gray-200">
{t("contentManagement.value")} *
</Label>
<Input
id="countValue"
type="number"
min="0"
value={countValue}
onChange={(e) => setCountValue(e.target.value)}
placeholder={t("contentManagement.enterValue")}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("contentManagement.currentCount")}:{" "}
{countPopup.currentCount}
</p>
</div>
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={closeCountPopup}>
{t("common.cancel")}
</Button>
<Button type="submit">{t("common.submit")}</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
// PrefixSuffixTable.tsx
import React, { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { Pencil, Trash2, X } from "lucide-react";
import { t } from "i18next";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { prefixSuffixService } from "@/user-management/services/api/prefixSuffixService";
import { useQueryClient } from "@tanstack/react-query";
interface PrefixSuffixTableProps {
items: any[];
isLoading: boolean;
onEdit: (item: any) => void;
onDelete: (id: string, type: string, recordType: string) => void;
showPositionColumn?: boolean;
showCountColumn?: boolean;
positions?: any[];
}
export const PrefixSuffixTable = ({
items,
isLoading,
onEdit,
onDelete,
showPositionColumn = false,
showCountColumn = false,
positions = [],
}: PrefixSuffixTableProps) => {
const queryClient = useQueryClient();
const getPositionName = (positionId: string) => {
const pos = positions.find((p) => p.id === positionId);
return pos?.name?.en || pos?.name?.am || positionId;
};
const [countPopup, setCountPopup] = useState<{
itemId: string;
currentCount: number;
} | null>(null);
const [countValue, setCountValue] = useState("");
// 1. Helper to get count from either source
const getCount = (item: any): number | undefined => {
return item.recordSequences?.[0]?.count ?? item.count ?? undefined;
};
// 2. Helper to get sequence ID from either source
const getSequenceId = (item: any): string | undefined => {
return item.recordSequences?.[0]?.id ?? item.sequenceId ?? undefined;
};
// 3. Updated openCountPopup
const openCountPopup = (item: any) => {
const currentCount = getCount(item) ?? 0;
const sequenceId = getSequenceId(item);
if (!sequenceId) {
console.error("No sequence ID found for item:", item);
return;
}
setCountPopup({ itemId: sequenceId, currentCount });
setCountValue(String(currentCount));
};
const closeCountPopup = () => {
setCountPopup(null);
setCountValue("");
queryClient.invalidateQueries({
queryKey: ["prefixes-by-position"],
});
queryClient.invalidateQueries({
queryKey: ["unit-reference-numbers"],
});
};
const handleCountSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!countPopup) return;
const newCount = parseInt(countValue, 10);
if (isNaN(newCount) || newCount < 0) return;
prefixSuffixService.updateCount(countPopup.itemId, newCount).then(() => {
closeCountPopup();
});
};
return (
<div className="overflow-x-auto border rounded-lg dark:border-gray-600">
<table className="min-w-full text-sm text-left">
<thead className="bg-gray-50 dark:bg-gray-700 border-b dark:border-gray-600">
<tr>
<th className="px-4 py-3 dark:text-white">#</th>
<th className="px-4 py-3 dark:text-white">{t("header.amharic")}</th>
<th className="px-4 py-3 dark:text-white">{t("header.english")}</th>
<th className="px-4 py-3 dark:text-white">
{t("contentManagement.type")}
</th>
{showPositionColumn && (
<th className="px-4 py-3 dark:text-white">
{t("contentManagement.position")}
</th>
)}
{showCountColumn && (
<th className="px-4 py-3 dark:text-white">
{t("contentManagement.count")}
</th>
)}
<th className="px-4 py-3 dark:text-white">
{t("userRecord.Actions")}
</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td
colSpan={showPositionColumn ? 6 : 5}
className="text-center py-8 text-gray-500 dark:text-gray-400">
{t("contentManagement.loading")}
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td
colSpan={showPositionColumn ? 6 : 5}
className="text-center py-8 text-gray-500 dark:text-gray-400">
{t("contentManagement.noRec")}
</td>
</tr>
) : (
items.map((item, idx) => {
return (
<tr
key={item.id}
className="border-b hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600">
<td className="px-4 py-3 dark:text-gray-300">{idx + 1}</td>
<td className="px-4 py-3 dark:text-gray-300">
{item.name?.am || "-"}
</td>
<td className="px-4 py-3 dark:text-gray-300">
{item.name?.en || "-"}
</td>
<td className="px-4 py-3 capitalize dark:text-gray-300">
{item.type}
{item.isForCC && (
<span className="ml-1 text-xs text-purple-600 dark:text-purple-400">
(CC)
</span>
)}
</td>
{showPositionColumn && (
<td className="px-4 py-3 dark:text-gray-300">
{item.positionId ? getPositionName(item.positionId) : "-"}
</td>
)}
{showCountColumn && (
<td className="px-4 py-3 dark:text-gray-300">
{(() => {
const count = getCount(item);
if (count === undefined) return "-";
return (
<button
onClick={() => openCountPopup(item)}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 underline cursor-pointer">
{count}
</button>
);
})()}
</td>
)}
<td className="px-4 py-3">
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(item)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
onClick={() =>
onDelete(item.id, item.type, item.recordTypeKey)
}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
{/* Count Update Popup */}
{countPopup && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 w-80">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold dark:text-white">
{t("contentManagement.updateCount")}
</h3>
<button
onClick={closeCountPopup}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleCountSubmit}>
<div className="space-y-2 mb-4">
<Label htmlFor="countValue" className="dark:text-gray-200">
{t("contentManagement.value")} *
</Label>
<Input
id="countValue"
type="number"
min="0"
value={countValue}
onChange={(e) => setCountValue(e.target.value)}
placeholder={t("contentManagement.enterValue")}
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("contentManagement.currentCount")}:{" "}
{countPopup.currentCount}
</p>
</div>
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={closeCountPopup}>
{t("common.cancel")}
</Button>
<Button type="submit">{t("common.submit")}</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
};

View File

@@ -1,223 +1,223 @@
import { useState, useEffect, useCallback } from "react";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from "@/shared/common/ui/card";
import { FileText, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { Activity } from "./ContentManagement";
import { useTranslation } from "react-i18next";
import i18n from "i18next";
import { Button } from "@/shared/common/ui/button";
import { listAuditLogExtensions, AuditLogExtensionItem } from "@/shared/services/audit/audit.api";
interface Props {
activities?: Activity[];
pageSize?: number;
}
const RecentActivitiesCard = ({ activities = [], pageSize = 10 }: Props) => {
const { t } = useTranslation();
const [auditLogs, setAuditLogs] = useState<AuditLogExtensionItem[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [allLogs, setAllLogs] = useState<AuditLogExtensionItem[]>([]);
const fetchAuditLogs = useCallback(async () => {
setLoading(true);
try {
// Fetch all 1000 records at once
const data = await listAuditLogExtensions(
"/audit-log-extensions/audit/unitAdmin",
{
skip: 0,
take: 1000,
orderBy: "createdAt:DESC",
}
);
const logs = data.items || [];
setAllLogs(logs);
setTotalCount(logs.length);
setCurrentPage(1); // Reset to first page
} catch (error: any) {
console.error("Error fetching audit logs:", error);
// Silently fail for now - don't show error toast on load
// as this is a supplementary feature
setAllLogs([]);
setTotalCount(0);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAuditLogs();
// Refresh every 30 seconds
const interval = setInterval(fetchAuditLogs, 30000);
return () => clearInterval(interval);
}, [fetchAuditLogs]);
// Paginate the allLogs whenever currentPage changes
useEffect(() => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
setAuditLogs(allLogs.slice(startIndex, endIndex));
}, [currentPage, pageSize, allLogs]);
const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "numeric",
hour12: true,
month: "short",
day: "numeric",
}).format(date);
const totalPages = Math.ceil(totalCount / pageSize);
const hasNextPage = currentPage < totalPages;
const hasPrevPage = currentPage > 1;
return (
<Card className="bg-gradient-to-br from-gray-50 to-slate-50 dark:from-gray-800 dark:to-gray-900 dark:border-gray-700">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex-1">
<CardTitle className="flex items-center dark:text-white">
<FileText className="h-5 w-5 mr-2" />
{t("dashboard.recentActivities", "Recent Activities")}
</CardTitle>
<CardDescription className="dark:text-gray-400">
{t("organization.recentContentActivities", "Recent content management activities")}
</CardDescription>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => fetchAuditLogs()}
disabled={loading}
className="ml-2"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
</CardHeader>
<CardContent>
{/* Display audit logs if available */}
{auditLogs.length > 0 ? (
<div className="space-y-3">
{auditLogs.map((log, idx) => {
const userName = i18n.language === "am" ? log.user.name.am : log.user.name.en;
const entityLabel = log.entityName.replace(/_/g, " ");
const timestamp = new Date(log.createdAt).toLocaleString(
i18n.language,
{
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}
);
return (
<div
key={log.id || idx}
className="p-3 rounded-lg bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:shadow-sm transition-shadow"
>
<p className="text-sm text-gray-900 dark:text-gray-100">
<span className="font-semibold">{userName}</span>
{" "}
<span className="text-gray-600 dark:text-gray-300">
{log.queryMethod.toLowerCase() === "insert"
? "created"
: log.queryMethod.toLowerCase() === "update"
? "updated"
: log.queryMethod.toLowerCase() === "delete"
? "deleted"
: log.queryMethod.toLowerCase()}
</span>
{" "}
<span className="text-gray-900 dark:text-gray-100 font-medium">
{entityLabel}
</span>
{" "}
<span className="text-gray-500 dark:text-gray-400">at {timestamp}</span>
</p>
</div>
);
})}
</div>
) : activities.length > 0 ? (
<div className="space-y-4">
{activities.map(({ id, type, description, timestamp }) => (
<div key={id} className="flex justify-between items-center">
<span className="text-sm font-medium capitalize dark:text-white">{type}</span>
<span className="text-sm dark:text-gray-300">{description}</span>
<span className="text-xs text-muted-foreground dark:text-gray-400">
{formatDate(timestamp)}
</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground dark:text-gray-400 text-center py-8">
{loading ? (
<div className="flex items-center justify-center gap-2">
<RefreshCw className="h-4 w-4 animate-spin" />
<span>{t("common.loading", "Loading...")}</span>
</div>
) : (
t("auditLog.noRecords", "No recent activities.")
)}
</p>
)}
</CardContent>
{/* Pagination Footer */}
{auditLogs.length > 0 && (
<CardFooter className="flex items-center justify-between border-t border-gray-200 dark:border-gray-600 pt-4">
<div className="text-xs text-gray-600 dark:text-gray-400">
{t("common.showing", "Showing")} {(currentPage - 1) * pageSize + 1}-
{Math.min(currentPage * pageSize, totalCount)} {t("common.of", "of")} {totalCount}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={!hasPrevPage || loading}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 px-2">
{currentPage} / {totalPages || 1}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={!hasNextPage || loading}
className="h-8 w-8 p-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardFooter>
)}
</Card>
);
};
export default RecentActivitiesCard;
import { useState, useEffect, useCallback } from "react";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from "@/shared/common/ui/card";
import { FileText, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import { Activity } from "./ContentManagement";
import { useTranslation } from "react-i18next";
import i18n from "i18next";
import { Button } from "@/shared/common/ui/button";
import { listAuditLogExtensions, AuditLogExtensionItem } from "@/shared/services/audit/audit.api";
interface Props {
activities?: Activity[];
pageSize?: number;
}
const RecentActivitiesCard = ({ activities = [], pageSize = 10 }: Props) => {
const { t } = useTranslation();
const [auditLogs, setAuditLogs] = useState<AuditLogExtensionItem[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [allLogs, setAllLogs] = useState<AuditLogExtensionItem[]>([]);
const fetchAuditLogs = useCallback(async () => {
setLoading(true);
try {
// Fetch all 1000 records at once
const data = await listAuditLogExtensions(
"/audit-log-extensions/audit/unitAdmin",
{
skip: 0,
take: 1000,
orderBy: "createdAt:DESC",
}
);
const logs = data.items || [];
setAllLogs(logs);
setTotalCount(logs.length);
setCurrentPage(1); // Reset to first page
} catch (error: any) {
console.error("Error fetching audit logs:", error);
// Silently fail for now - don't show error toast on load
// as this is a supplementary feature
setAllLogs([]);
setTotalCount(0);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAuditLogs();
// Refresh every 30 seconds
const interval = setInterval(fetchAuditLogs, 30000);
return () => clearInterval(interval);
}, [fetchAuditLogs]);
// Paginate the allLogs whenever currentPage changes
useEffect(() => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
setAuditLogs(allLogs.slice(startIndex, endIndex));
}, [currentPage, pageSize, allLogs]);
const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "numeric",
hour12: true,
month: "short",
day: "numeric",
}).format(date);
const totalPages = Math.ceil(totalCount / pageSize);
const hasNextPage = currentPage < totalPages;
const hasPrevPage = currentPage > 1;
return (
<Card className="bg-gradient-to-br from-gray-50 to-slate-50 dark:from-gray-800 dark:to-gray-900 dark:border-gray-700">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex-1">
<CardTitle className="flex items-center dark:text-white">
<FileText className="h-5 w-5 mr-2" />
{t("dashboard.recentActivities", "Recent Activities")}
</CardTitle>
<CardDescription className="dark:text-gray-400">
{t("organization.recentContentActivities", "Recent content management activities")}
</CardDescription>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => fetchAuditLogs()}
disabled={loading}
className="ml-2"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
</CardHeader>
<CardContent>
{/* Display audit logs if available */}
{auditLogs.length > 0 ? (
<div className="space-y-3">
{auditLogs.map((log, idx) => {
const userName = i18n.language === "am" ? log.user.name.am : log.user.name.en;
const entityLabel = log.entityName.replace(/_/g, " ");
const timestamp = new Date(log.createdAt).toLocaleString(
i18n.language,
{
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}
);
return (
<div
key={log.id || idx}
className="p-3 rounded-lg bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:shadow-sm transition-shadow"
>
<p className="text-sm text-gray-900 dark:text-gray-100">
<span className="font-semibold">{userName}</span>
{" "}
<span className="text-gray-600 dark:text-gray-300">
{log.queryMethod.toLowerCase() === "insert"
? "created"
: log.queryMethod.toLowerCase() === "update"
? "updated"
: log.queryMethod.toLowerCase() === "delete"
? "deleted"
: log.queryMethod.toLowerCase()}
</span>
{" "}
<span className="text-gray-900 dark:text-gray-100 font-medium">
{entityLabel}
</span>
{" "}
<span className="text-gray-500 dark:text-gray-400">at {timestamp}</span>
</p>
</div>
);
})}
</div>
) : activities.length > 0 ? (
<div className="space-y-4">
{activities.map(({ id, type, description, timestamp }) => (
<div key={id} className="flex justify-between items-center">
<span className="text-sm font-medium capitalize dark:text-white">{type}</span>
<span className="text-sm dark:text-gray-300">{description}</span>
<span className="text-xs text-muted-foreground dark:text-gray-400">
{formatDate(timestamp)}
</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground dark:text-gray-400 text-center py-8">
{loading ? (
<div className="flex items-center justify-center gap-2">
<RefreshCw className="h-4 w-4 animate-spin" />
<span>{t("common.loading", "Loading...")}</span>
</div>
) : (
t("auditLog.noRecords", "No recent activities.")
)}
</p>
)}
</CardContent>
{/* Pagination Footer */}
{auditLogs.length > 0 && (
<CardFooter className="flex items-center justify-between border-t border-gray-200 dark:border-gray-600 pt-4">
<div className="text-xs text-gray-600 dark:text-gray-400">
{t("common.showing", "Showing")} {(currentPage - 1) * pageSize + 1}-
{Math.min(currentPage * pageSize, totalCount)} {t("common.of", "of")} {totalCount}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={!hasPrevPage || loading}
className="h-8 w-8 p-0"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 px-2">
{currentPage} / {totalPages || 1}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={!hasNextPage || loading}
className="h-8 w-8 p-0"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</CardFooter>
)}
</Card>
);
};
export default RecentActivitiesCard;

View File

@@ -1,351 +1,351 @@
// components/record-tag-selector.tsx
"use client";
import * as React from "react";
import { X, Check, ChevronsUpDown, Tag, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/common/ui/popover";
import { cn } from "@/shared/lib/utils";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import { useLocalizedName } from "@/shared/common/localizedName";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/shared/common/ui/command";
// ─── Types ─────────────────────────────────────────────
export interface TagSelectorProps {
/** Unit ID to fetch tags from */
unitId: string;
/** Currently selected tag IDs */
selectedTagIds: string[];
/** Callback when selection changes */
onChange: (tagIds: string[]) => void;
/** Placeholder text */
placeholder?: string;
/** Allow multiple selection */
multiple?: boolean;
/** Disable the selector */
disabled?: boolean;
/** Custom className */
className?: string;
/** Maximum number of tags to show before collapsing */
maxDisplayTags?: number;
/** Optional error state */
error?: string;
}
// ─── Component ─────────────────────────────────────────
export function RecordTagSelector({
unitId,
selectedTagIds,
onChange,
placeholder = "Select tags...",
multiple = true,
disabled = false,
className,
maxDisplayTags = 3,
error,
}: TagSelectorProps) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const localizedName = useLocalizedName();
const { recordTagsList, isLoadingRecordTagsList, isErrorRecordTagsList } =
useRecordTags({ unitId });
const tags = recordTagsList?.items ?? [];
const selectedTags = tags.filter((tag) => selectedTagIds.includes(tag.id));
// Toggle tag selection
const toggleTag = React.useCallback(
(tagId: string) => {
if (multiple) {
onChange(
selectedTagIds.includes(tagId)
? selectedTagIds.filter((id) => id !== tagId)
: [...selectedTagIds, tagId],
);
} else {
onChange(selectedTagIds.includes(tagId) ? [] : [tagId]);
setOpen(false);
}
},
[multiple, onChange, selectedTagIds],
);
// Remove a specific tag
const removeTag = React.useCallback(
(e: React.MouseEvent, tagId: string) => {
e.stopPropagation();
onChange(selectedTagIds.filter((id) => id !== tagId));
},
[onChange, selectedTagIds],
);
// Clear all selections
const clearAll = React.useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onChange([]);
},
[onChange],
);
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || isLoadingRecordTagsList}
className={cn(
"w-full justify-between min-h-[40px] h-auto px-3 py-2",
!multiple && selectedTags.length > 0 && "justify-start gap-2",
error && "border-destructive ring-destructive",
"hover:bg-accent",
)}
>
{isLoadingRecordTagsList ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">{t("common.loading")}</span>
</div>
) : selectedTags.length === 0 ? (
<span className="text-muted-foreground text-sm">
{placeholder}
</span>
) : multiple ? (
<div className="flex flex-wrap items-center gap-1.5 flex-1">
{selectedTags.slice(0, maxDisplayTags).map((tag: any) => (
<Badge
key={tag.id}
variant="secondary"
className="gap-1 px-2 py-0.5 text-xs font-medium cursor-default"
style={{
backgroundColor: tag.color ? `${tag.color}20` : undefined,
color: tag.color,
borderColor: tag.color,
}}
>
{tag.name}
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={(e) => removeTag(e, tag.id)}
/>
</Badge>
))}
{selectedTags.length > maxDisplayTags && (
<Badge variant="secondary" className="text-xs">
+{selectedTags.length - maxDisplayTags}
</Badge>
)}
</div>
) : (
<div className="flex items-center gap-2 flex-1">
<span className="text-sm">
{localizedName(selectedTags[0].name)}
</span>
</div>
)}
<div className="flex items-center gap-1 shrink-0 ml-2">
{selectedTags.length > 0 && !disabled && (
<X
className="h-4 w-4 text-muted-foreground hover:text-foreground cursor-pointer"
onClick={clearAll}
/>
)}
<ChevronsUpDown className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command>
<CommandInput
placeholder={t("common.search") || "Search tags..."}
/>
<CommandList>
<CommandEmpty>
{isErrorRecordTagsList ? (
<div className="py-6 text-center text-sm text-destructive">
{t("common.errorLoading")}
</div>
) : (
t("common.noResults") || "No tags found."
)}
</CommandEmpty>
<CommandGroup>
{tags.map((tag) => {
const isSelected = selectedTagIds.includes(tag.id);
return (
<CommandItem
key={tag.id}
value={tag.id}
onSelect={() => toggleTag(tag.id)}
className="cursor-pointer"
>
<div className="flex items-center gap-3 flex-1">
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50",
)}
>
{isSelected && <Check className="h-3 w-3" />}
</div>
<span className="flex-1 text-sm">
{localizedName(tag.name)}
</span>
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
// ─── Single Select Variant ─────────────────────────────
export function RecordTagSelectorSingle(
props: Omit<TagSelectorProps, "multiple" | "maxDisplayTags">,
) {
return <RecordTagSelector {...props} multiple={false} />;
}
// ─── Display Only (Read) Component ─────────────────────
export interface TagListProps {
tagIds: string[];
unitId: string;
className?: string;
size?: "sm" | "md" | "lg";
}
export function RecordTagList({
tagIds,
unitId,
className,
size = "md",
}: TagListProps) {
const localizedName = useLocalizedName();
const { recordTagsList, isLoadingRecordTagsList } = useRecordTags({ unitId });
const tags = recordTagsList?.items ?? [];
const selectedTags = tags.filter((tag) => tagIds.includes(tag.id));
const sizeClasses = {
sm: "text-[10px] px-1.5 py-0",
md: "text-xs px-2 py-0.5",
lg: "text-sm px-2.5 py-1",
};
if (isLoadingRecordTagsList) {
return <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />;
}
if (selectedTags.length === 0) {
return <span className="text-xs text-muted-foreground"></span>;
}
return (
<div className={cn("flex flex-wrap gap-1.5", className)}>
{selectedTags.map((tag) => (
<Badge
key={tag.id}
variant="outline"
className={cn("font-medium gap-1.5", sizeClasses[size])}
>
<span className="h-1.5 w-1.5 rounded-full" />
{localizedName(tag.name)}
</Badge>
))}
</div>
);
}
// ─── Create Tag Dialog Integration ─────────────────────
export interface TagSelectorWithCreateProps extends TagSelectorProps {
onCreateTag?: (name: string) => void;
isCreatingTag?: boolean;
}
export function RecordTagSelectorWithCreate({
onCreateTag,
isCreatingTag,
...props
}: TagSelectorWithCreateProps) {
const { t } = useTranslation();
const [newTagName, setNewTagName] = React.useState("");
const handleCreate = React.useCallback(() => {
if (newTagName.trim() && onCreateTag) {
onCreateTag(newTagName.trim());
setNewTagName("");
}
}, [newTagName, onCreateTag]);
return (
<div className="space-y-2">
<RecordTagSelector {...props} />
{onCreateTag && (
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Tag className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
type="text"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
placeholder={t("tags.createPlaceholder") || "Create new tag..."}
className="w-full h-8 pl-8 pr-3 text-xs rounded-md border border-input bg-background
focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
/>
</div>
<Button
size="sm"
variant="secondary"
className="h-8 px-3 text-xs"
onClick={handleCreate}
disabled={!newTagName.trim() || isCreatingTag}
>
{isCreatingTag ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
t("common.create") || "Create"
)}
</Button>
</div>
)}
</div>
);
}
// components/record-tag-selector.tsx
"use client";
import * as React from "react";
import { X, Check, ChevronsUpDown, Tag, Loader2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/common/ui/popover";
import { cn } from "@/shared/lib/utils";
import { Button } from "@/shared/common/ui/button";
import { Badge } from "@/shared/common/ui/badge";
import { useLocalizedName } from "@/shared/common/localizedName";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/shared/common/ui/command";
// ─── Types ─────────────────────────────────────────────
export interface TagSelectorProps {
/** Unit ID to fetch tags from */
unitId: string;
/** Currently selected tag IDs */
selectedTagIds: string[];
/** Callback when selection changes */
onChange: (tagIds: string[]) => void;
/** Placeholder text */
placeholder?: string;
/** Allow multiple selection */
multiple?: boolean;
/** Disable the selector */
disabled?: boolean;
/** Custom className */
className?: string;
/** Maximum number of tags to show before collapsing */
maxDisplayTags?: number;
/** Optional error state */
error?: string;
}
// ─── Component ─────────────────────────────────────────
export function RecordTagSelector({
unitId,
selectedTagIds,
onChange,
placeholder = "Select tags...",
multiple = true,
disabled = false,
className,
maxDisplayTags = 3,
error,
}: TagSelectorProps) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const localizedName = useLocalizedName();
const { recordTagsList, isLoadingRecordTagsList, isErrorRecordTagsList } =
useRecordTags({ unitId });
const tags = recordTagsList?.items ?? [];
const selectedTags = tags.filter((tag) => selectedTagIds.includes(tag.id));
// Toggle tag selection
const toggleTag = React.useCallback(
(tagId: string) => {
if (multiple) {
onChange(
selectedTagIds.includes(tagId)
? selectedTagIds.filter((id) => id !== tagId)
: [...selectedTagIds, tagId],
);
} else {
onChange(selectedTagIds.includes(tagId) ? [] : [tagId]);
setOpen(false);
}
},
[multiple, onChange, selectedTagIds],
);
// Remove a specific tag
const removeTag = React.useCallback(
(e: React.MouseEvent, tagId: string) => {
e.stopPropagation();
onChange(selectedTagIds.filter((id) => id !== tagId));
},
[onChange, selectedTagIds],
);
// Clear all selections
const clearAll = React.useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onChange([]);
},
[onChange],
);
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || isLoadingRecordTagsList}
className={cn(
"w-full justify-between min-h-[40px] h-auto px-3 py-2",
!multiple && selectedTags.length > 0 && "justify-start gap-2",
error && "border-destructive ring-destructive",
"hover:bg-accent",
)}
>
{isLoadingRecordTagsList ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">{t("common.loading")}</span>
</div>
) : selectedTags.length === 0 ? (
<span className="text-muted-foreground text-sm">
{placeholder}
</span>
) : multiple ? (
<div className="flex flex-wrap items-center gap-1.5 flex-1">
{selectedTags.slice(0, maxDisplayTags).map((tag: any) => (
<Badge
key={tag.id}
variant="secondary"
className="gap-1 px-2 py-0.5 text-xs font-medium cursor-default"
style={{
backgroundColor: tag.color ? `${tag.color}20` : undefined,
color: tag.color,
borderColor: tag.color,
}}
>
{tag.name}
<X
className="h-3 w-3 cursor-pointer hover:text-destructive"
onClick={(e) => removeTag(e, tag.id)}
/>
</Badge>
))}
{selectedTags.length > maxDisplayTags && (
<Badge variant="secondary" className="text-xs">
+{selectedTags.length - maxDisplayTags}
</Badge>
)}
</div>
) : (
<div className="flex items-center gap-2 flex-1">
<span className="text-sm">
{localizedName(selectedTags[0].name)}
</span>
</div>
)}
<div className="flex items-center gap-1 shrink-0 ml-2">
{selectedTags.length > 0 && !disabled && (
<X
className="h-4 w-4 text-muted-foreground hover:text-foreground cursor-pointer"
onClick={clearAll}
/>
)}
<ChevronsUpDown className="h-4 w-4 text-muted-foreground shrink-0" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command>
<CommandInput
placeholder={t("common.search") || "Search tags..."}
/>
<CommandList>
<CommandEmpty>
{isErrorRecordTagsList ? (
<div className="py-6 text-center text-sm text-destructive">
{t("common.errorLoading")}
</div>
) : (
t("common.noResults") || "No tags found."
)}
</CommandEmpty>
<CommandGroup>
{tags.map((tag) => {
const isSelected = selectedTagIds.includes(tag.id);
return (
<CommandItem
key={tag.id}
value={tag.id}
onSelect={() => toggleTag(tag.id)}
className="cursor-pointer"
>
<div className="flex items-center gap-3 flex-1">
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50",
)}
>
{isSelected && <Check className="h-3 w-3" />}
</div>
<span className="flex-1 text-sm">
{localizedName(tag.name)}
</span>
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
// ─── Single Select Variant ─────────────────────────────
export function RecordTagSelectorSingle(
props: Omit<TagSelectorProps, "multiple" | "maxDisplayTags">,
) {
return <RecordTagSelector {...props} multiple={false} />;
}
// ─── Display Only (Read) Component ─────────────────────
export interface TagListProps {
tagIds: string[];
unitId: string;
className?: string;
size?: "sm" | "md" | "lg";
}
export function RecordTagList({
tagIds,
unitId,
className,
size = "md",
}: TagListProps) {
const localizedName = useLocalizedName();
const { recordTagsList, isLoadingRecordTagsList } = useRecordTags({ unitId });
const tags = recordTagsList?.items ?? [];
const selectedTags = tags.filter((tag) => tagIds.includes(tag.id));
const sizeClasses = {
sm: "text-[10px] px-1.5 py-0",
md: "text-xs px-2 py-0.5",
lg: "text-sm px-2.5 py-1",
};
if (isLoadingRecordTagsList) {
return <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />;
}
if (selectedTags.length === 0) {
return <span className="text-xs text-muted-foreground"></span>;
}
return (
<div className={cn("flex flex-wrap gap-1.5", className)}>
{selectedTags.map((tag) => (
<Badge
key={tag.id}
variant="outline"
className={cn("font-medium gap-1.5", sizeClasses[size])}
>
<span className="h-1.5 w-1.5 rounded-full" />
{localizedName(tag.name)}
</Badge>
))}
</div>
);
}
// ─── Create Tag Dialog Integration ─────────────────────
export interface TagSelectorWithCreateProps extends TagSelectorProps {
onCreateTag?: (name: string) => void;
isCreatingTag?: boolean;
}
export function RecordTagSelectorWithCreate({
onCreateTag,
isCreatingTag,
...props
}: TagSelectorWithCreateProps) {
const { t } = useTranslation();
const [newTagName, setNewTagName] = React.useState("");
const handleCreate = React.useCallback(() => {
if (newTagName.trim() && onCreateTag) {
onCreateTag(newTagName.trim());
setNewTagName("");
}
}, [newTagName, onCreateTag]);
return (
<div className="space-y-2">
<RecordTagSelector {...props} />
{onCreateTag && (
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Tag className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
type="text"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
placeholder={t("tags.createPlaceholder") || "Create new tag..."}
className="w-full h-8 pl-8 pr-3 text-xs rounded-md border border-input bg-background
focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
/>
</div>
<Button
size="sm"
variant="secondary"
className="h-8 px-3 text-xs"
onClick={handleCreate}
disabled={!newTagName.trim() || isCreatingTag}
>
{isCreatingTag ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
t("common.create") || "Create"
)}
</Button>
</div>
)}
</div>
);
}

View File

@@ -1,375 +1,375 @@
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/shared/common/ui/card";
import { Stamp, X, Plus } from "lucide-react";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { Button } from "@/shared/common/ui/button";
import { useEffect, useState, useRef } from "react";
import { useSeal } from "@/user-management/hooks/useSeal";
import {
CreateSealPayload,
sealService,
} from "@/user-management/services/api/sealService";
import { toast } from "sonner";
import { FilePreview } from "./ContentManagement";
import { presignedAxios } from "@/shared/services/presignedAxios";
import { t } from "i18next";
import { useQueryClient } from "@tanstack/react-query";
interface Props {
unitId: string;
}
const SealCard = ({ unitId }: Props) => {
const {
createSeal,
isCreatingSeal,
isLoadingSeals,
deleteSeal,
getSealsByUnitId,
} = useSeal();
const [file, setFile] = useState<File>();
const [previews, setPreviews] = useState<FilePreview[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [sealName, setSealName] = useState("");
const [sealNameError, setSealNameError] = useState("");
const MAX_SIZE = 1 * 1024 * 1024;
const { data: seals } = getSealsByUnitId(unitId);
// -------------------------------
// Fetch existing seals
// -------------------------------
useEffect(() => {
let isMounted = true;
const fetchPresigned = async () => {
if (!seals?.items) return;
const filtered = seals.items.filter(
(s) => s.uploadedSuccessfully && s.isCurrent
);
const resolved = await Promise.all(
filtered.map(async (seal): Promise<FilePreview | null> => {
try {
const sealDetail = await sealService.getSeal(seal.id);
const presigned = sealDetail.data.presigned;
if (!presigned) return null;
return {
type: "seal",
file: new File([], seal.fileInfo.fileName),
url: presigned,
uploadedAt: new Date(seal.createdAt),
id: seal.id,
};
} catch {
return null;
}
})
);
const filteredPreviews: FilePreview[] = resolved.filter(
(p): p is FilePreview => p !== null
);
if (isMounted) {
setPreviews(filteredPreviews);
}
};
fetchPresigned();
return () => {
isMounted = false;
previews.forEach((p) => {
if (!p.id && p.url.startsWith("blob:")) {
URL.revokeObjectURL(p.url);
}
});
};
}, [seals?.items]);
// -------------------------------
// File validation + preview
// -------------------------------
const handleFileUpload = (selectedFile: File) => {
if (selectedFile.type !== "image/png") {
toast.error(t("contentManagement.onlyPngAllowed"));
return;
}
if (selectedFile.size > MAX_SIZE) {
toast.error("Image size should be less than 1 MB");
return;
}
const tempUrl = URL.createObjectURL(selectedFile);
const img = new Image();
img.onload = () => {
if (img.width !== img.height) {
toast.error(t("contentManagement.onlySquareAllowed"));
URL.revokeObjectURL(tempUrl);
return;
}
setFile(selectedFile);
setSealName("");
setSealNameError("");
setPreviews([
{
type: "seal",
url: tempUrl,
file: selectedFile,
uploadedAt: new Date(),
id: null,
},
]);
};
img.onerror = () => {
toast.error(t("contentManagement.invalidImage"));
URL.revokeObjectURL(tempUrl);
};
img.src = tempUrl;
};
// -------------------------------
// Upload flow (CREATE + PUT)
// -------------------------------
const uploadSeal = async () => {
if (!file) {
toast.error(t("contentManagement.noFileSelected"));
return;
}
if (!sealName.trim()) {
setSealNameError(t("contentManagement.sealNameMsg"));
return;
}
try {
// Step 1: Create seal (get presigned URL)
const payload: CreateSealPayload = {
fileInfo: {
fileName: file.name,
contentType: file.type,
size: file.size,
originalname: file.name,
},
name: {
am: sealName,
en: sealName,
},
unitId,
};
const data = await createSeal(payload);
// Step 2: Upload file
setIsUploading(true);
setUploadProgress(0);
await presignedAxios.put(data.presigned, file, {
headers: {
"Content-Type": file.type,
},
});
// await updateSealStatus({
// id: data.id,
// updateSealStatusPayload: {
// id: data.id,
// parentId: data.id,
// uploadedSuccessfully: true,
// },
// });
setIsUploading(false);
setUploadProgress(100);
// Reset UI
setFile(undefined);
setSealName("");
setSealNameError("");
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
queryClient.invalidateQueries({ queryKey: ["seal"] });
toast.success(t("contentManagement.sealSuccessMsg"));
} catch (error) {
console.error(error);
toast.error(t("contentManagement.sealErrorMsg"));
setIsUploading(false);
setUploadProgress(0);
setFile(undefined);
}
};
// -------------------------------
// Delete seal
// -------------------------------
const handleRemove = async (id: string | null) => {
if (!id) return;
try {
await deleteSeal(id);
setPreviews((prev) => prev.filter((p) => p.id !== id));
toast.success(t("contentManagement.sealRemove"));
} catch (error) {
console.error(error);
toast.error(t("contentManagement.sealRemove"));
}
};
const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "numeric",
hour12: true,
month: "short",
day: "numeric",
}).format(date);
const isProcessing = isCreatingSeal || isUploading;
return (
<Card className="dark:border-gray-700 dark:bg-gray-800">
<CardHeader>
<CardTitle className="flex items-center dark:text-white">
<Stamp className="h-5 w-5 mr-2" />
{t("contentManagement.seal")}
</CardTitle>
<CardDescription className="dark:text-gray-400">
{t("contentManagement.uploadSealMsg")} (PNG, max 1MB)
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<Label htmlFor="seal" className="dark:text-gray-200">{t("contentManagement.sealImage")}</Label>
<Button
variant="outline"
className="bg-primary hover:bg-primary/90 text-primary-foreground"
onClick={() => fileInputRef.current?.click()}
disabled={isProcessing}
>
<Plus className="h-4 w-4 mr-2" />
{t("contentManagement.addSeal")}
</Button>
</div>
<Input
ref={fileInputRef}
type="file"
accept=".png,image/png"
className="hidden"
onChange={(e) =>
e.target.files?.[0] && handleFileUpload(e.target.files[0])
}
disabled={isProcessing}
/>
{/* PREVIEWS */}
{previews.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{previews.map((preview) => (
<div
key={preview?.id || preview?.url}
className="border rounded-lg p-3 bg-gray-50 dark:bg-gray-700 dark:border-gray-600"
>
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-muted-foreground dark:text-gray-400">
{formatDate(preview?.uploadedAt)}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(preview?.id || null)}
className="text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/30"
disabled={isLoadingSeals}
>
<X className="h-4 w-4 text-red-500" />
</Button>
</div>
<img
src={preview.url}
alt="Seal"
className="max-h-32 mx-auto object-contain"
/>
</div>
))}
</div>
) : (
<div className="text-center p-4 border border-dashed rounded-lg dark:border-gray-600 dark:bg-gray-700/30">
<p className="text-muted-foreground dark:text-gray-400">
{t("contentManagement.noSealMsg")}
</p>
<p className="text-sm text-muted-foreground dark:text-gray-500 mt-1">
{t("contentManagement.addSealInstruction")}
</p>
</div>
)}
{/* NAME INPUT */}
{file && (
<div className="space-y-2">
<Label htmlFor="sealName" className="dark:text-gray-200">{t("contentManagement.sealName")}</Label>
<Input
value={sealName}
onChange={(e) => {
setSealName(e.target.value);
setSealNameError("");
}}
placeholder="Enter seal name"
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
{sealNameError && (
<p className="text-sm text-red-500">{sealNameError}</p>
)}
</div>
)}
{/* UPLOAD BUTTON (MAIN UX CONTROL) */}
{file && (
<Button
className="w-full bg-primary hover:bg-primary/90"
onClick={uploadSeal}
disabled={isProcessing || !file}
>
{isCreatingSeal && "Creating seal..."}
{isUploading && `Uploading ${uploadProgress}%`}
{!isProcessing && t("contentManagement.saveSeal")}
</Button>
)}
</CardContent>
</Card>
);
};
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/shared/common/ui/card";
import { Stamp, X, Plus } from "lucide-react";
import { Input } from "@/shared/common/ui/input";
import { Label } from "@/shared/common/ui/label";
import { Button } from "@/shared/common/ui/button";
import { useEffect, useState, useRef } from "react";
import { useSeal } from "@/user-management/hooks/useSeal";
import {
CreateSealPayload,
sealService,
} from "@/user-management/services/api/sealService";
import { toast } from "sonner";
import { FilePreview } from "./ContentManagement";
import { presignedAxios } from "@/shared/services/presignedAxios";
import { t } from "i18next";
import { useQueryClient } from "@tanstack/react-query";
interface Props {
unitId: string;
}
const SealCard = ({ unitId }: Props) => {
const {
createSeal,
isCreatingSeal,
isLoadingSeals,
deleteSeal,
getSealsByUnitId,
} = useSeal();
const [file, setFile] = useState<File>();
const [previews, setPreviews] = useState<FilePreview[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [sealName, setSealName] = useState("");
const [sealNameError, setSealNameError] = useState("");
const MAX_SIZE = 1 * 1024 * 1024;
const { data: seals } = getSealsByUnitId(unitId);
// -------------------------------
// Fetch existing seals
// -------------------------------
useEffect(() => {
let isMounted = true;
const fetchPresigned = async () => {
if (!seals?.items) return;
const filtered = seals.items.filter(
(s) => s.uploadedSuccessfully && s.isCurrent
);
const resolved = await Promise.all(
filtered.map(async (seal): Promise<FilePreview | null> => {
try {
const sealDetail = await sealService.getSeal(seal.id);
const presigned = sealDetail.data.presigned;
if (!presigned) return null;
return {
type: "seal",
file: new File([], seal.fileInfo.fileName),
url: presigned,
uploadedAt: new Date(seal.createdAt),
id: seal.id,
};
} catch {
return null;
}
})
);
const filteredPreviews: FilePreview[] = resolved.filter(
(p): p is FilePreview => p !== null
);
if (isMounted) {
setPreviews(filteredPreviews);
}
};
fetchPresigned();
return () => {
isMounted = false;
previews.forEach((p) => {
if (!p.id && p.url.startsWith("blob:")) {
URL.revokeObjectURL(p.url);
}
});
};
}, [seals?.items]);
// -------------------------------
// File validation + preview
// -------------------------------
const handleFileUpload = (selectedFile: File) => {
if (selectedFile.type !== "image/png") {
toast.error(t("contentManagement.onlyPngAllowed"));
return;
}
if (selectedFile.size > MAX_SIZE) {
toast.error("Image size should be less than 1 MB");
return;
}
const tempUrl = URL.createObjectURL(selectedFile);
const img = new Image();
img.onload = () => {
if (img.width !== img.height) {
toast.error(t("contentManagement.onlySquareAllowed"));
URL.revokeObjectURL(tempUrl);
return;
}
setFile(selectedFile);
setSealName("");
setSealNameError("");
setPreviews([
{
type: "seal",
url: tempUrl,
file: selectedFile,
uploadedAt: new Date(),
id: null,
},
]);
};
img.onerror = () => {
toast.error(t("contentManagement.invalidImage"));
URL.revokeObjectURL(tempUrl);
};
img.src = tempUrl;
};
// -------------------------------
// Upload flow (CREATE + PUT)
// -------------------------------
const uploadSeal = async () => {
if (!file) {
toast.error(t("contentManagement.noFileSelected"));
return;
}
if (!sealName.trim()) {
setSealNameError(t("contentManagement.sealNameMsg"));
return;
}
try {
// Step 1: Create seal (get presigned URL)
const payload: CreateSealPayload = {
fileInfo: {
fileName: file.name,
contentType: file.type,
size: file.size,
originalname: file.name,
},
name: {
am: sealName,
en: sealName,
},
unitId,
};
const data = await createSeal(payload);
// Step 2: Upload file
setIsUploading(true);
setUploadProgress(0);
await presignedAxios.put(data.presigned, file, {
headers: {
"Content-Type": file.type,
},
});
// await updateSealStatus({
// id: data.id,
// updateSealStatusPayload: {
// id: data.id,
// parentId: data.id,
// uploadedSuccessfully: true,
// },
// });
setIsUploading(false);
setUploadProgress(100);
// Reset UI
setFile(undefined);
setSealName("");
setSealNameError("");
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
queryClient.invalidateQueries({ queryKey: ["seal"] });
toast.success(t("contentManagement.sealSuccessMsg"));
} catch (error) {
console.error(error);
toast.error(t("contentManagement.sealErrorMsg"));
setIsUploading(false);
setUploadProgress(0);
setFile(undefined);
}
};
// -------------------------------
// Delete seal
// -------------------------------
const handleRemove = async (id: string | null) => {
if (!id) return;
try {
await deleteSeal(id);
setPreviews((prev) => prev.filter((p) => p.id !== id));
toast.success(t("contentManagement.sealRemove"));
} catch (error) {
console.error(error);
toast.error(t("contentManagement.sealRemove"));
}
};
const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "numeric",
hour12: true,
month: "short",
day: "numeric",
}).format(date);
const isProcessing = isCreatingSeal || isUploading;
return (
<Card className="dark:border-gray-700 dark:bg-gray-800">
<CardHeader>
<CardTitle className="flex items-center dark:text-white">
<Stamp className="h-5 w-5 mr-2" />
{t("contentManagement.seal")}
</CardTitle>
<CardDescription className="dark:text-gray-400">
{t("contentManagement.uploadSealMsg")} (PNG, max 1MB)
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<Label htmlFor="seal" className="dark:text-gray-200">{t("contentManagement.sealImage")}</Label>
<Button
variant="outline"
className="bg-primary hover:bg-primary/90 text-primary-foreground"
onClick={() => fileInputRef.current?.click()}
disabled={isProcessing}
>
<Plus className="h-4 w-4 mr-2" />
{t("contentManagement.addSeal")}
</Button>
</div>
<Input
ref={fileInputRef}
type="file"
accept=".png,image/png"
className="hidden"
onChange={(e) =>
e.target.files?.[0] && handleFileUpload(e.target.files[0])
}
disabled={isProcessing}
/>
{/* PREVIEWS */}
{previews.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{previews.map((preview) => (
<div
key={preview?.id || preview?.url}
className="border rounded-lg p-3 bg-gray-50 dark:bg-gray-700 dark:border-gray-600"
>
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-muted-foreground dark:text-gray-400">
{formatDate(preview?.uploadedAt)}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemove(preview?.id || null)}
className="text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/30"
disabled={isLoadingSeals}
>
<X className="h-4 w-4 text-red-500" />
</Button>
</div>
<img
src={preview.url}
alt="Seal"
className="max-h-32 mx-auto object-contain"
/>
</div>
))}
</div>
) : (
<div className="text-center p-4 border border-dashed rounded-lg dark:border-gray-600 dark:bg-gray-700/30">
<p className="text-muted-foreground dark:text-gray-400">
{t("contentManagement.noSealMsg")}
</p>
<p className="text-sm text-muted-foreground dark:text-gray-500 mt-1">
{t("contentManagement.addSealInstruction")}
</p>
</div>
)}
{/* NAME INPUT */}
{file && (
<div className="space-y-2">
<Label htmlFor="sealName" className="dark:text-gray-200">{t("contentManagement.sealName")}</Label>
<Input
value={sealName}
onChange={(e) => {
setSealName(e.target.value);
setSealNameError("");
}}
placeholder="Enter seal name"
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
{sealNameError && (
<p className="text-sm text-red-500">{sealNameError}</p>
)}
</div>
)}
{/* UPLOAD BUTTON (MAIN UX CONTROL) */}
{file && (
<Button
className="w-full bg-primary hover:bg-primary/90"
onClick={uploadSeal}
disabled={isProcessing || !file}
>
{isCreatingSeal && "Creating seal..."}
{isUploading && `Uploading ${uploadProgress}%`}
{!isProcessing && t("contentManagement.saveSeal")}
</Button>
)}
</CardContent>
</Card>
);
};
export default SealCard;

View File

@@ -1,126 +1,126 @@
import { useState } from "react";
import { Pencil, Trash2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
import { RecordTag } from "@/user-management/dto/recordTags/recordTags.type";
import { RecordTagForm } from "./RecordTagForm";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
interface Props {
row: RecordTag;
unitId: string;
}
export default function RecordTagActionsCell({ row, unitId }: Props) {
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false); // confirmation dialog
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const { deleteRecordTag, refetchRecordTagsList } = useRecordTags({ unitId });
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
// --- Handle Delete ---
const confirmDelete = () => {
setIsDeleteLoading(true);
deleteRecordTag(row.id, {
onSuccess: () => {
refetchRecordTagsList();
setIsDeleteOpen(false);
},
onError: (error) => {
handleError(error);
},
onSettled: () => {
setIsDeleteLoading(false);
},
});
};
return (
<>
<div className="flex items-center gap-2">
{/* Edit Button */}
<Button variant="outline" size="sm" onClick={() => setIsEditOpen(true)}>
<Pencil className="h-4 w-4" />
</Button>
{/* Delete Button (opens confirm) */}
<Button
variant="destructive"
size="sm"
onClick={() => setIsDeleteOpen(true)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Edit Dialog */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Record Tag</DialogTitle>
</DialogHeader>
<RecordTagForm
unitId={unitId}
mode="edit"
id={row.id}
defaultValues={{
nameAm: row.name?.am ?? "",
nameEn: row.name?.en ?? "",
key: row.key,
unitId: row.unitId,
}}
onSuccess={() => {
setIsEditOpen(false);
refetchRecordTagsList();
}}
/>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to delete this record tag?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The record tag <b>{row.name?.en}</b>{" "}
will be permanently removed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleteLoading}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isDeleteLoading}
className="bg-red-600 hover:bg-red-700">
{isDeleteLoading ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
import { useState } from "react";
import { Pencil, Trash2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/common/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
} from "@/shared/common/ui/alert-dialog";
import { Button } from "@/shared/common/ui/button";
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
import { RecordTag } from "@/user-management/dto/recordTags/recordTags.type";
import { RecordTagForm } from "./RecordTagForm";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
interface Props {
row: RecordTag;
unitId: string;
}
export default function RecordTagActionsCell({ row, unitId }: Props) {
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false); // confirmation dialog
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const { deleteRecordTag, refetchRecordTagsList } = useRecordTags({ unitId });
const { t } = useTranslation();
const { handleError } = useErrorHandler(t);
// --- Handle Delete ---
const confirmDelete = () => {
setIsDeleteLoading(true);
deleteRecordTag(row.id, {
onSuccess: () => {
refetchRecordTagsList();
setIsDeleteOpen(false);
},
onError: (error) => {
handleError(error);
},
onSettled: () => {
setIsDeleteLoading(false);
},
});
};
return (
<>
<div className="flex items-center gap-2">
{/* Edit Button */}
<Button variant="outline" size="sm" onClick={() => setIsEditOpen(true)}>
<Pencil className="h-4 w-4" />
</Button>
{/* Delete Button (opens confirm) */}
<Button
variant="destructive"
size="sm"
onClick={() => setIsDeleteOpen(true)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Edit Dialog */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Record Tag</DialogTitle>
</DialogHeader>
<RecordTagForm
unitId={unitId}
mode="edit"
id={row.id}
defaultValues={{
nameAm: row.name?.am ?? "",
nameEn: row.name?.en ?? "",
key: row.key,
unitId: row.unitId,
}}
onSuccess={() => {
setIsEditOpen(false);
refetchRecordTagsList();
}}
/>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to delete this record tag?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The record tag <b>{row.name?.en}</b>{" "}
will be permanently removed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleteLoading}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isDeleteLoading}
className="bg-red-600 hover:bg-red-700">
{isDeleteLoading ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -1,153 +1,153 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/shared/common/ui/button";
import {
Form,
FormField,
FormItem,
FormLabel,
FormMessage,
FormControl,
} from "@/shared/common/ui/form";
import { Input } from "@/shared/common/ui/input";
import {
CreateRecordTagPayload,
UpdateRecordTagPayload,
} from "@/user-management/dto/recordTags/recordTags.type";
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
const recordTagSchema = z.object({
nameAm: z.string().min(1, "Amharic name is required"),
nameEn: z.string().min(1, "English name is required"),
key: z.string().min(1, "Key is required"),
unitId: z.string().min(1, "Unit is required"),
});
export type RecordTagFormValues = z.infer<typeof recordTagSchema>;
interface RecordTagFormProps {
defaultValues?: Partial<RecordTagFormValues>;
unitId: string;
mode: "create" | "edit";
id?: string; // required if edit
onSuccess?: () => void;
}
export function RecordTagForm({
defaultValues,
unitId,
mode,
id,
onSuccess,
}: RecordTagFormProps) {
const form = useForm<RecordTagFormValues>({
resolver: zodResolver(recordTagSchema),
defaultValues: {
nameAm: defaultValues?.nameAm ?? "",
nameEn: defaultValues?.nameEn ?? "",
key: defaultValues?.key ?? "",
unitId: defaultValues?.unitId ?? unitId,
},
});
const {
createRecordTag,
updateRecordTag,
isCreatingRecordTag,
isUpdatingRecordTag,
} = useRecordTags({ unitId });
const handleSubmit = (values: RecordTagFormValues) => {
if (mode === "create") {
const payload: CreateRecordTagPayload = {
name: { am: values.nameAm, en: values.nameEn },
key: values.key,
unitId: values.unitId,
};
createRecordTag(payload, {
onSuccess: () => {
form.reset();
onSuccess?.();
},
});
} else if (mode === "edit" && id) {
const payload: UpdateRecordTagPayload = {
name: { am: values.nameAm, en: values.nameEn },
key: values.key,
unitId: values.unitId,
};
updateRecordTag(
{ id, payload },
{
onSuccess: () => {
onSuccess?.();
},
}
);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="nameAm"
render={({ field }) => (
<FormItem>
<FormLabel>Amharic Name</FormLabel>
<FormControl>
<Input placeholder="Enter Amharic name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nameEn"
render={({ field }) => (
<FormItem>
<FormLabel>English Name</FormLabel>
<FormControl>
<Input placeholder="Enter English name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="key"
render={({ field }) => (
<FormItem>
<FormLabel>Key</FormLabel>
<FormControl>
<Input placeholder="Unique key" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isCreatingRecordTag || isUpdatingRecordTag}
>
{mode === "create"
? isCreatingRecordTag
? "Creating..."
: "Create"
: isUpdatingRecordTag
? "Updating..."
: "Update"}
</Button>
</form>
</Form>
);
}
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/shared/common/ui/button";
import {
Form,
FormField,
FormItem,
FormLabel,
FormMessage,
FormControl,
} from "@/shared/common/ui/form";
import { Input } from "@/shared/common/ui/input";
import {
CreateRecordTagPayload,
UpdateRecordTagPayload,
} from "@/user-management/dto/recordTags/recordTags.type";
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
const recordTagSchema = z.object({
nameAm: z.string().min(1, "Amharic name is required"),
nameEn: z.string().min(1, "English name is required"),
key: z.string().min(1, "Key is required"),
unitId: z.string().min(1, "Unit is required"),
});
export type RecordTagFormValues = z.infer<typeof recordTagSchema>;
interface RecordTagFormProps {
defaultValues?: Partial<RecordTagFormValues>;
unitId: string;
mode: "create" | "edit";
id?: string; // required if edit
onSuccess?: () => void;
}
export function RecordTagForm({
defaultValues,
unitId,
mode,
id,
onSuccess,
}: RecordTagFormProps) {
const form = useForm<RecordTagFormValues>({
resolver: zodResolver(recordTagSchema),
defaultValues: {
nameAm: defaultValues?.nameAm ?? "",
nameEn: defaultValues?.nameEn ?? "",
key: defaultValues?.key ?? "",
unitId: defaultValues?.unitId ?? unitId,
},
});
const {
createRecordTag,
updateRecordTag,
isCreatingRecordTag,
isUpdatingRecordTag,
} = useRecordTags({ unitId });
const handleSubmit = (values: RecordTagFormValues) => {
if (mode === "create") {
const payload: CreateRecordTagPayload = {
name: { am: values.nameAm, en: values.nameEn },
key: values.key,
unitId: values.unitId,
};
createRecordTag(payload, {
onSuccess: () => {
form.reset();
onSuccess?.();
},
});
} else if (mode === "edit" && id) {
const payload: UpdateRecordTagPayload = {
name: { am: values.nameAm, en: values.nameEn },
key: values.key,
unitId: values.unitId,
};
updateRecordTag(
{ id, payload },
{
onSuccess: () => {
onSuccess?.();
},
}
);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="nameAm"
render={({ field }) => (
<FormItem>
<FormLabel>Amharic Name</FormLabel>
<FormControl>
<Input placeholder="Enter Amharic name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nameEn"
render={({ field }) => (
<FormItem>
<FormLabel>English Name</FormLabel>
<FormControl>
<Input placeholder="Enter English name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="key"
render={({ field }) => (
<FormItem>
<FormLabel>Key</FormLabel>
<FormControl>
<Input placeholder="Unique key" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isCreatingRecordTag || isUpdatingRecordTag}
>
{mode === "create"
? isCreatingRecordTag
? "Creating..."
: "Create"
: isUpdatingRecordTag
? "Updating..."
: "Update"}
</Button>
</form>
</Form>
);
}

View File

@@ -1,89 +1,89 @@
import { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/common/ui/dialog";
import { useTranslation } from "react-i18next";
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
import { RecordTagColumnDefn } from "./RecordTagsColumn";
import { RecordTagForm } from "./RecordTagForm";
export default function RecordTagsManagement({ unitId }: { unitId: string }) {
const [pageIndex, setPageIndex] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation();
// --- Record Tags Query ---
const { recordTagsList, isLoadingRecordTagsList, refetchRecordTagsList } =
useRecordTags({
unitId: unitId ?? "",
});
const handlePageChange = (newPage: number) => {
setPageIndex(newPage);
};
if (isLoadingRecordTagsList) {
return <div>{t("loading")}</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("recordTag.title")}
</CardTitle>
{/* Create Button (opens modal) */}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>{t("recordTag.createNew")}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("recordTag.createTitle")}</DialogTitle>
</DialogHeader>
<RecordTagForm
unitId={unitId}
mode="create"
onSuccess={() => {
setIsOpen(false);
refetchRecordTagsList();
}}
/>
</DialogContent>
</Dialog>
</CardHeader>
{/* Record Tags Table */}
<CardContent className="px-0">
<AdvancedTable
columns={RecordTagColumnDefn(unitId)}
data={recordTagsList?.items || []}
tableName={t("recordTag.tableName")}
toolBarPosition="right"
itemCount={recordTagsList?.count || 0}
pageIndex={pageIndex}
onPageChange={handlePageChange}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
/>
</CardContent>
</Card>
</div>
);
}
import { useState } from "react";
import { Button } from "@/shared/common/ui/button";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/common/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/common/ui/dialog";
import { useTranslation } from "react-i18next";
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
import { RecordTagColumnDefn } from "./RecordTagsColumn";
import { RecordTagForm } from "./RecordTagForm";
export default function RecordTagsManagement({ unitId }: { unitId: string }) {
const [pageIndex, setPageIndex] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation();
// --- Record Tags Query ---
const { recordTagsList, isLoadingRecordTagsList, refetchRecordTagsList } =
useRecordTags({
unitId: unitId ?? "",
});
const handlePageChange = (newPage: number) => {
setPageIndex(newPage);
};
if (isLoadingRecordTagsList) {
return <div>{t("loading")}</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("recordTag.title")}
</CardTitle>
{/* Create Button (opens modal) */}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>{t("recordTag.createNew")}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("recordTag.createTitle")}</DialogTitle>
</DialogHeader>
<RecordTagForm
unitId={unitId}
mode="create"
onSuccess={() => {
setIsOpen(false);
refetchRecordTagsList();
}}
/>
</DialogContent>
</Dialog>
</CardHeader>
{/* Record Tags Table */}
<CardContent className="px-0">
<AdvancedTable
columns={RecordTagColumnDefn(unitId)}
data={recordTagsList?.items || []}
tableName={t("recordTag.tableName")}
toolBarPosition="right"
itemCount={recordTagsList?.count || 0}
pageIndex={pageIndex}
onPageChange={handlePageChange}
nextFunction={() => handlePageChange(pageIndex + 1)}
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,48 +1,48 @@
import { ColumnDef } from "@tanstack/react-table";
import { RecordTag } from "@/user-management/dto/recordTags/recordTags.type";
import { format } from "date-fns";
import { t } from "i18next";
import RecordTagActionsCell from "./RecordTagActionsCell";
import { useLocalizedName } from "@/shared/common/localizedName";
export const RecordTagColumnDefn = (unitId: string): ColumnDef<RecordTag>[] => {
return [
{
accessorKey: "name",
header: () => t("recordTag.name"),
cell: ({ row }) => {
const localizedName = useLocalizedName();
const name = row.original?.name;
return <span>{localizedName(name)}</span>;
},
},
{
accessorKey: "key",
header: () => t("recordTag.key"),
cell: ({ row }) => {
const key = row.original?.key;
return <span>{key || "--"}</span>;
},
},
{
accessorKey: "createdAt",
header: () => t("recordTag.CreatedAt"),
cell: ({ row }) => {
const date = row.original?.createdAt;
return (
<span>
{date ? format(new Date(date), "MMM d, yyyy HH:mm") : "--"}
</span>
);
},
},
{
id: "actions",
header: () => t("recordTag.Actions"),
cell: ({ row }) => (
<RecordTagActionsCell row={row.original} unitId={unitId} />
),
},
];
};
import { ColumnDef } from "@tanstack/react-table";
import { RecordTag } from "@/user-management/dto/recordTags/recordTags.type";
import { format } from "date-fns";
import { t } from "i18next";
import RecordTagActionsCell from "./RecordTagActionsCell";
import { useLocalizedName } from "@/shared/common/localizedName";
export const RecordTagColumnDefn = (unitId: string): ColumnDef<RecordTag>[] => {
return [
{
accessorKey: "name",
header: () => t("recordTag.name"),
cell: ({ row }) => {
const localizedName = useLocalizedName();
const name = row.original?.name;
return <span>{localizedName(name)}</span>;
},
},
{
accessorKey: "key",
header: () => t("recordTag.key"),
cell: ({ row }) => {
const key = row.original?.key;
return <span>{key || "--"}</span>;
},
},
{
accessorKey: "createdAt",
header: () => t("recordTag.CreatedAt"),
cell: ({ row }) => {
const date = row.original?.createdAt;
return (
<span>
{date ? format(new Date(date), "MMM d, yyyy HH:mm") : "--"}
</span>
);
},
},
{
id: "actions",
header: () => t("recordTag.Actions"),
cell: ({ row }) => (
<RecordTagActionsCell row={row.original} unitId={unitId} />
),
},
];
};

View File

@@ -1,36 +1,36 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
export function useDebounceCallback<T extends (...args: any[]) => any>(
callback: T,
delay: number
): T {
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
return ((...args: Parameters<T>) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
const newTimeoutId = setTimeout(() => {
callback(...args);
}, delay);
setTimeoutId(newTimeoutId);
}) as T;
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
export function useDebounceCallback<T extends (...args: any[]) => any>(
callback: T,
delay: number
): T {
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
return ((...args: Parameters<T>) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
const newTimeoutId = setTimeout(() => {
callback(...args);
}, delay);
setTimeoutId(newTimeoutId);
}) as T;
}