mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
user management ui
This commit is contained in:
@@ -0,0 +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;
|
||||
@@ -0,0 +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} />,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +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;
|
||||
@@ -0,0 +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;
|
||||
@@ -0,0 +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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,508 @@
|
||||
import SealCard from "./SealCard";
|
||||
import LetterTemplatesTable from "./LetterTemplates/LetterTemplatesTable";
|
||||
import RecentActivitiesCard from "./RecentActivitiesCard";
|
||||
import { CommonRemarks } from "./CommonRemarks";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import { useUnit } from "@/user-management/hooks/useUnit";
|
||||
import HeaderAndFooter from "./HeaderAndFooter";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { UnitDto } from "@/user-management/dto/unit/unitDto";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { PrefixAndSuffix } from "./PrefixAndSuffixCard";
|
||||
import { t } from "i18next";
|
||||
import i18n from "@/i18n";
|
||||
import AdminSetupAlert from "@/layout/components/AlertShow";
|
||||
import RecordTagsManagement from "./record-tags/RecordTags";
|
||||
import { TemplateSamplePage } from "@/user-management/TemplateSample/TemplateSamplePage";
|
||||
import {
|
||||
BookImage,
|
||||
ClipboardType,
|
||||
ChevronRight,
|
||||
Menu,
|
||||
PencilRuler,
|
||||
SquareActivity,
|
||||
Stamp,
|
||||
Tag,
|
||||
X,
|
||||
Building,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { useDebounce } from "../content/useDebounce";
|
||||
import { SingleSelect } from "@/shared/common/ui/single-select";
|
||||
|
||||
export interface FilePreview {
|
||||
type: "header" | "footer" | "seal";
|
||||
url: string;
|
||||
file: File;
|
||||
uploadedAt: Date;
|
||||
id: string | null;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
element?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ContentManagement = () => {
|
||||
const { user } = useAuth();
|
||||
const lang = i18n.language;
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
const [selectedUnitId, setSelectedUnitId] = useState<string>("");
|
||||
const debouncedUnitId = useDebounce(selectedUnitId, 300);
|
||||
|
||||
const organizationId =
|
||||
user?.employee && user.employee.length > 0
|
||||
? user.employee[0].organizationId
|
||||
: undefined;
|
||||
|
||||
const { getAccessibleList } = useUnit();
|
||||
const { data: unitsResponse, isLoading: unitsLoading } = organizationId
|
||||
? getAccessibleList(organizationId, { take: 300, skip: 0 })
|
||||
: { data: undefined, isLoading: false };
|
||||
|
||||
// Notify listeners (e.g. AdminSetupAlert) after the unit selection debounces.
|
||||
useEffect(() => {
|
||||
if (!debouncedUnitId) return;
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("unitChanged", { detail: debouncedUnitId }),
|
||||
);
|
||||
}, [debouncedUnitId]);
|
||||
|
||||
const handleUnitChange = useCallback(
|
||||
(value: string) => {
|
||||
setSelectedUnitId(value);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set("unit", value);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const selectMenuItem = useCallback(
|
||||
(index: number, label: string) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set("tab", label);
|
||||
if (selectedUnitId) {
|
||||
next.set("unit", selectedUnitId);
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
setIsMobileMenuOpen(false);
|
||||
},
|
||||
[selectedUnitId, setSearchParams],
|
||||
);
|
||||
|
||||
// Close mobile menu when resizing to desktop
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth >= 768) {
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const unitFromUrl = searchParams.get("unit");
|
||||
const items = unitsResponse?.data?.items;
|
||||
if (
|
||||
unitFromUrl &&
|
||||
items?.some((unit: UnitDto) => unit.id === unitFromUrl) &&
|
||||
unitFromUrl !== selectedUnitId
|
||||
) {
|
||||
setSelectedUnitId(unitFromUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!unitsLoading &&
|
||||
items?.length &&
|
||||
!selectedUnitId &&
|
||||
!unitFromUrl
|
||||
) {
|
||||
const firstUnitId = items[0].id;
|
||||
setSelectedUnitId(firstUnitId);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set("unit", firstUnitId);
|
||||
if (!next.get("tab")) {
|
||||
next.set("tab", "contentManagement.seal");
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
}, [
|
||||
unitsLoading,
|
||||
unitsResponse,
|
||||
selectedUnitId,
|
||||
searchParams,
|
||||
setSearchParams,
|
||||
]);
|
||||
|
||||
// Sidebar menu items with dynamic loading states
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
label: "contentManagement.seal",
|
||||
icon: <Stamp className="h-5 w-5 flex-shrink-0" />,
|
||||
element: <SealCard unitId={selectedUnitId} />,
|
||||
},
|
||||
{
|
||||
label: "contentManagement.letterTemplate",
|
||||
icon: <ClipboardList className="h-5 w-5 flex-shrink-0" />,
|
||||
element: <LetterTemplatesTable unitId={selectedUnitId} />,
|
||||
},
|
||||
{
|
||||
label: "recordTag.title",
|
||||
icon: <Tag className="h-5 w-5 flex-shrink-0" />,
|
||||
element: <RecordTagsManagement unitId={selectedUnitId} />,
|
||||
},
|
||||
{
|
||||
label: "contentManagement.prefix",
|
||||
icon: <PencilRuler className="h-5 w-5 flex-shrink-0" />,
|
||||
element: (
|
||||
<PrefixAndSuffix unitId={selectedUnitId} initialTab="internal" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "contentManagement.commonRemarks",
|
||||
icon: <ClipboardType className="h-5 w-5 flex-shrink-0" />,
|
||||
element: <CommonRemarks unitId={selectedUnitId} />,
|
||||
},
|
||||
{
|
||||
label: "contentManagement.headerAndFooter",
|
||||
icon: <BookImage className="h-5 w-5 flex-shrink-0" />,
|
||||
element: <HeaderAndFooter unitId={selectedUnitId} />,
|
||||
},
|
||||
{
|
||||
label: "template.sample",
|
||||
icon: <FileText className="h-5 w-5 flex-shrink-0" />,
|
||||
element: <TemplateSamplePage unitId={selectedUnitId} />,
|
||||
},
|
||||
{
|
||||
label: "dashboard.recentActivities",
|
||||
icon: <SquareActivity className="h-5 w-5 flex-shrink-0" />,
|
||||
element: <RecentActivitiesCard />,
|
||||
},
|
||||
];
|
||||
|
||||
const activeMenuIndex = useMemo(() => {
|
||||
const tab = searchParams.get("tab");
|
||||
if (!tab) return 0;
|
||||
const index = menuItems.findIndex((item) => item.label === tab);
|
||||
return index >= 0 ? index : 0;
|
||||
}, [menuItems, searchParams]);
|
||||
|
||||
const activeMenuItem = menuItems[activeMenuIndex];
|
||||
const isTemplateSampleActive = activeMenuItem?.label === "template.sample";
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchParams.get("tab") && menuItems.length > 0) {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set("tab", menuItems[0].label);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
}, [menuItems, searchParams, setSearchParams]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100/30 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="w-full h-full p-4 lg:p-6 space-y-6">
|
||||
{/* Header Section */}
|
||||
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900 dark:text-white tracking-tight">
|
||||
{t("organization.contentManagement")}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2 text-sm lg:text-base">
|
||||
{t("organization.contentMsg")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Admin Alert */}
|
||||
<div className="w-full lg:w-auto">
|
||||
<AdminSetupAlert />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unit Selection Card */}
|
||||
{unitsResponse?.data?.items?.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-4 lg:p-6 transition-all duration-200 hover:shadow-md">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div className="p-2 bg-purple-50 dark:bg-purple-900/30 rounded-lg">
|
||||
<Building className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("organization.selectUnit")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<SingleSelect
|
||||
options={unitsResponse?.data.items.map((u: any) => ({
|
||||
value: u.id,
|
||||
label: lang === "en" ? u.name.en : u.name.am,
|
||||
}))}
|
||||
onValueChange={handleUnitChange}
|
||||
value={selectedUnitId ?? ""}
|
||||
placeholder={t("delegation.selectDelegatedPosition")}
|
||||
/>
|
||||
|
||||
{/* <Select
|
||||
value={selectedUnitId}
|
||||
onValueChange={handleUnitChange}
|
||||
disabled={unitsLoading}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"w-full border-gray-300 dark:border-gray-600 rounded-xl shadow-sm transition-all duration-200 dark:bg-gray-700 dark:text-white",
|
||||
"focus:ring-2 focus:ring-purple-500 focus:border-purple-500",
|
||||
"hover:border-gray-400 dark:hover:border-gray-500",
|
||||
unitsLoading && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
unitsLoading
|
||||
? t("common.loading")
|
||||
: t("organization.selectUnit")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl border border-gray-200 dark:border-gray-600 shadow-lg dark:bg-gray-800">
|
||||
{unitsResponse?.data.items.map((unit: UnitDto) => (
|
||||
<SelectItem
|
||||
key={unit.id}
|
||||
value={unit.id}
|
||||
className="rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors dark:text-gray-200"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1 truncate">
|
||||
{lang === "en" ? unit.name.en : unit.name.am}
|
||||
</span>
|
||||
{unit.id === selectedUnitId && (
|
||||
<div className="w-2 h-2 bg-purple-600 dark:bg-purple-400 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full min-h-[calc(100vh-12rem)] bg-transparent rounded-2xl",
|
||||
isTemplateSampleActive ? "overflow-visible" : "overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{/* Mobile Overlay */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 dark:bg-black/70 z-40 md:hidden backdrop-blur-sm transition-opacity duration-300"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={cn(
|
||||
// Remove `fixed` and use `sticky` instead for proper scroll behavior
|
||||
"md:sticky top-0 flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200/60 dark:border-gray-700 transition-all duration-300 ease-in-out",
|
||||
"backdrop-blur-sm bg-white/95 md:bg-white dark:bg-gray-800/95",
|
||||
isMobileMenuOpen
|
||||
? "absolute left-0 top-0 z-40 w-72 lg:w-80"
|
||||
: "hidden md:flex",
|
||||
isSidebarCollapsed ? "md:w-16 lg:w-20" : "md:w-72 lg:w-80",
|
||||
"h-[calc(100vh-2rem)] md:h-screen rounded-2xl md:rounded-none shadow-xl md:shadow-none",
|
||||
)}
|
||||
>
|
||||
{/* Sidebar Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center p-4 border-b border-gray-200/60 dark:border-gray-700 transition-all duration-300",
|
||||
isSidebarCollapsed ? "justify-center" : "justify-between",
|
||||
)}
|
||||
>
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<h2 className="text-lg font-bold text-gray-900 dark:text-white truncate">
|
||||
{t("organization.contentManagement")}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Collapse Toggle - Desktop */}
|
||||
<button
|
||||
onClick={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
className={cn(
|
||||
"hidden md:flex p-2 rounded-xl hover:bg-purple-200 dark:hover:bg-purple-900/50 transition-all duration-200",
|
||||
"hover:shadow-sm border border-transparent hover:border-purple-200 dark:hover:border-purple-800",
|
||||
)}
|
||||
title={
|
||||
isSidebarCollapsed
|
||||
? t("expandSidebar")
|
||||
: t("collapseSidebar")
|
||||
}
|
||||
>
|
||||
{isSidebarCollapsed ? (
|
||||
<Menu className="h-4 w-4 text-gray-600 dark:text-gray-300" />
|
||||
) : (
|
||||
<Menu className="h-4 w-4 text-gray-600 dark:text-gray-300" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Close Button - Mobile */}
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="md:hidden p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4 text-gray-600 dark:text-gray-300" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Menu */}
|
||||
<nav className="flex-1 p-3 space-y-1 overflow-y-auto">
|
||||
{menuItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => selectMenuItem(index, item.label)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 p-3 rounded-xl text-sm font-medium cursor-pointer transition-all duration-200",
|
||||
"border border-transparent hover:border-gray-200 dark:hover:border-gray-600 hover:shadow-sm",
|
||||
isSidebarCollapsed ? "justify-center" : "",
|
||||
activeMenuIndex === index
|
||||
? "bg-gradient-to-r from-purple-50 to-purple-50 dark:from-purple-900/30 dark:to-purple-900/20 text-purple-700 dark:text-purple-300 border-purple-200 dark:border-purple-700 shadow-sm"
|
||||
: "text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"transition-transform duration-200",
|
||||
activeMenuIndex === index && "scale-110",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
{!isSidebarCollapsed && (
|
||||
<span className="flex-1 text-left truncate font-semibold">
|
||||
{t(item.label)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex flex-col min-w-0 transition-all duration-300",
|
||||
isSidebarCollapsed ? "md:ml-0" : "md:ml-0",
|
||||
)}
|
||||
>
|
||||
{/* Mobile Header */}
|
||||
<div className="md:hidden flex items-center justify-between p-4 bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm border-b border-gray-200/60 dark:border-gray-700 rounded-t-2xl">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
className="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors shadow-sm border border-gray-200 dark:border-gray-600"
|
||||
>
|
||||
<Menu className="h-5 w-5 text-gray-600 dark:text-gray-300" />
|
||||
</button>
|
||||
<div className="flex-1 text-center">
|
||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
|
||||
{t(activeMenuItem?.label || "contentmanagement")}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="w-9"></div>
|
||||
</div>
|
||||
|
||||
{/* Page Content */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 p-4 md:p-6",
|
||||
isTemplateSampleActive ? "overflow-visible" : "overflow-auto",
|
||||
)}
|
||||
>
|
||||
{/* Breadcrumb Navigation */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-6 flex-wrap">
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
{t("content")}
|
||||
</span>
|
||||
{activeMenuItem && (
|
||||
<>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
<span className="text-gray-900 dark:text-white font-semibold bg-gray-100 dark:bg-gray-700 px-3 py-1 rounded-full text-sm">
|
||||
{t(activeMenuItem.label)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Render the active element with loading state */}
|
||||
<div className={cn("w-full transition-opacity duration-300")}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl shadow-sm border border-gray-200/60 dark:border-gray-700",
|
||||
isTemplateSampleActive
|
||||
? "overflow-visible"
|
||||
: "overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{activeMenuItem?.element}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentManagement;
|
||||
@@ -0,0 +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;
|
||||
@@ -0,0 +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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +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;
|
||||
@@ -0,0 +1,701 @@
|
||||
import React, { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/common/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useLetterTemplates } from "@/user-management/hooks/useLetterTemplates";
|
||||
import { LetterTemplateForm } from "./LetterTemplateForm";
|
||||
import { LetterTemplate } from "@/user-management/services/api/letterTemplateService";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { useTemplate } from "@/super-admin/components/templates/service/useTemplate";
|
||||
import { headerFooterService } from "@/user-management/services/api/headerFooterService";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
interface Props {
|
||||
unitId: string;
|
||||
}
|
||||
|
||||
const LetterTemplatesTable = ({ unitId }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
// Pagination is server-side: skip/take are passed to the BE so we only
|
||||
// pull one page at a time instead of every template at once.
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const {
|
||||
letterTemplatesResponse,
|
||||
count: localCount,
|
||||
isLoading: isLocalLoading,
|
||||
isFetching: isLocalFetching,
|
||||
isError: isLocalError,
|
||||
refetch,
|
||||
createLetterTemplate,
|
||||
updateLetterTemplate,
|
||||
deleteLetterTemplate,
|
||||
isCreating,
|
||||
isUpdating,
|
||||
isDeleting,
|
||||
} = useLetterTemplates(unitId, {
|
||||
skip: pageIndex * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(localCount / pageSize));
|
||||
|
||||
const {
|
||||
templates: globalTemplatesResponse,
|
||||
isLoading: isGlobalLoading,
|
||||
adoptTemplate,
|
||||
isAdoptingTemplate,
|
||||
} = useTemplate();
|
||||
|
||||
const { data: headersResponse } = useQuery({
|
||||
queryKey: ["headers", unitId],
|
||||
queryFn: () => headerFooterService.getHeadersByUnitId(unitId),
|
||||
});
|
||||
|
||||
const { data: footersResponse } = useQuery({
|
||||
queryKey: ["footers", unitId],
|
||||
queryFn: () => headerFooterService.getFootersByUnitId(unitId),
|
||||
});
|
||||
|
||||
const localizedName = useLocalizedName();
|
||||
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isAdoptDialogOpen, setIsAdoptDialogOpen] = useState(false);
|
||||
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<any | null>(null);
|
||||
const [selectedHeaderId, setSelectedHeaderId] = useState<string>("");
|
||||
const [selectedFooterId, setSelectedFooterId] = useState<string>("");
|
||||
|
||||
const deleteTemplateName = selectedTemplate
|
||||
? localizedName(selectedTemplate.name)
|
||||
: "";
|
||||
|
||||
const isLoading = isLocalLoading || isGlobalLoading;
|
||||
const isError = isLocalError;
|
||||
|
||||
const globalTemplates = Array.isArray(globalTemplatesResponse)
|
||||
? globalTemplatesResponse
|
||||
: globalTemplatesResponse?.items || [];
|
||||
const localTemplates = letterTemplatesResponse?.items || [];
|
||||
|
||||
const combinedTemplates = [
|
||||
...globalTemplates.map((t: any) => ({ ...t, isGlobal: true })),
|
||||
...localTemplates.map((t: any) => ({ ...t, isGlobal: false })),
|
||||
];
|
||||
const preventDialogCloseFromTinyMce = (event: Event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
|
||||
if (
|
||||
target?.closest(
|
||||
".tox-tinymce-aux, .moxman-window, .tam-assetmanager-root",
|
||||
)
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = (values: any) => {
|
||||
createLetterTemplate(values, {
|
||||
onSuccess: (newTemplate) => {
|
||||
toast.success(
|
||||
`${localizedName(newTemplate.name)} ${t(
|
||||
"contentManagement.templateSuccessMsg",
|
||||
)}`,
|
||||
);
|
||||
refetch();
|
||||
setIsCreateDialogOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdate = (values: any) => {
|
||||
if (!selectedTemplate) return;
|
||||
|
||||
updateLetterTemplate(
|
||||
{ id: selectedTemplate.id, data: values },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("contentManagement.updateTemplate"));
|
||||
refetch();
|
||||
setIsEditDialogOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!selectedTemplate) return;
|
||||
|
||||
deleteLetterTemplate(
|
||||
{ id: selectedTemplate.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("contentManagement.deleteTemplate"));
|
||||
refetch();
|
||||
setIsDeleteDialogOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleAdopt = () => {
|
||||
if (!selectedTemplate || !selectedHeaderId || !selectedFooterId) {
|
||||
toast.error(
|
||||
t(
|
||||
"contentManagement.pleaseSelectHeaderAndFooter",
|
||||
"Please select header and footer",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
adoptTemplate(
|
||||
{
|
||||
templateId: selectedTemplate.id,
|
||||
headerId: selectedHeaderId,
|
||||
footerId: selectedFooterId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t(
|
||||
"contentManagement.adoptSuccess",
|
||||
"Successfully adopted global template",
|
||||
),
|
||||
);
|
||||
refetch();
|
||||
setIsAdoptDialogOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
setSelectedHeaderId("");
|
||||
setSelectedFooterId("");
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const openEditDialog = (template: any) => {
|
||||
setSelectedTemplate(template);
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const openViewDialog = (template: any) => {
|
||||
setSelectedTemplate(template);
|
||||
setIsViewDialogOpen(true);
|
||||
};
|
||||
|
||||
const openDeleteDialog = (template: any) => {
|
||||
setSelectedTemplate(template);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const openAdoptDialog = (template: any) => {
|
||||
setSelectedTemplate(template);
|
||||
setIsAdoptDialogOpen(true);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
{t("contentManagement.letterTemplate")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{Array(3)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<Skeleton key={index} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
{t("contentManagement.letterTemplate")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{t("contentManagement.failedToLoadTemplates")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
{t("contentManagement.letterTemplate")}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("contentManagement.createMsg")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("contentManagement.addTemplate")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{combinedTemplates.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<FileText className="mx-auto mb-4 h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t("contentManagement.noTemplates")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[150px]">
|
||||
{t("common.name")}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[200px] hidden md:table-cell">
|
||||
{t("contentManagement.sincerelyText")}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[120px] hidden sm:table-cell">
|
||||
{t("common.createdDate")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right min-w-[120px]">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{combinedTemplates.map((template) => (
|
||||
<TableRow
|
||||
key={template.id}
|
||||
className={
|
||||
template.isGlobal
|
||||
? "bg-blue-50/50 hover:bg-blue-50 dark:bg-blue-950/30 dark:hover:bg-blue-900/40"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-2">
|
||||
{localizedName(template.name)}
|
||||
{template.isGlobal && (
|
||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/50 dark:text-blue-200">
|
||||
Global
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 md:hidden">
|
||||
{template.sincerelyText &&
|
||||
template.sincerelyText.length > 30
|
||||
? `${template.sincerelyText.substring(0, 30)}...`
|
||||
: template.sincerelyText ||
|
||||
t("common.notAvailable")}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate hidden md:table-cell">
|
||||
{template.sincerelyText || t("common.notAvailable")}
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
{template.createdAt
|
||||
? new Date(template.createdAt).toLocaleDateString()
|
||||
: t("common.notAvailable")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openViewDialog(template)}
|
||||
title={t("contentManagement.viewTemplate")}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{template.isGlobal ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openAdoptDialog(template)}
|
||||
title={t(
|
||||
"contentManagement.adoptTemplate",
|
||||
"Adopt Template",
|
||||
)}
|
||||
className="h-8 border-blue-200 px-3 text-blue-700 hover:bg-blue-100 hover:text-blue-800 dark:border-blue-700/60 dark:text-blue-300 dark:hover:bg-blue-900/40 dark:hover:text-blue-200"
|
||||
>
|
||||
{t("common.adopt", "Adopt")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(template)}
|
||||
title={t("contentManagement.editTemplate")}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openDeleteDialog(template)}
|
||||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
title={t("contentManagement.deleteTemplate")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Pagination controls — apply to local templates only.
|
||||
Globals are typically a small fixed list and are shown
|
||||
in the same table on every page. */}
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("common.page", "Page")} {pageIndex + 1} / {totalPages}
|
||||
{localCount > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
• {localCount}{" "}
|
||||
{t("contentManagement.letterTemplate", "templates")}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(v) => {
|
||||
setPageSize(Number(v));
|
||||
setPageIndex(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[80px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[10, 20, 50, 100].map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pageIndex === 0 || isLocalFetching}
|
||||
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t("common.previous", "Previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pageIndex + 1 >= totalPages || isLocalFetching}
|
||||
onClick={() => setPageIndex((p) => p + 1)}
|
||||
>
|
||||
{t("common.next", "Next")}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogContent
|
||||
className="max-h-[90vh] w-full max-w-[95vw] overflow-y-auto lg:max-w-[1200px] dark:border-gray-700 dark:bg-gray-900"
|
||||
onInteractOutside={preventDialogCloseFromTinyMce}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("contentManagement.createTemplate")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<LetterTemplateForm
|
||||
unitId={unitId}
|
||||
isSubmitting={isCreating}
|
||||
onCancel={() => setIsCreateDialogOpen(false)}
|
||||
onSubmitCreate={handleCreate}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent
|
||||
className="max-h-[90vh] w-full max-w-[95vw] overflow-y-auto lg:max-w-[1200px] dark:border-gray-700 dark:bg-gray-900"
|
||||
onInteractOutside={preventDialogCloseFromTinyMce}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("contentManagement.editTemplate")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<LetterTemplateForm
|
||||
unitId={unitId}
|
||||
template={selectedTemplate}
|
||||
isSubmitting={isUpdating}
|
||||
onCancel={() => {
|
||||
setIsEditDialogOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
}}
|
||||
onSubmitCreate={handleUpdate}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* View Dialog */}
|
||||
<Dialog open={isViewDialogOpen} onOpenChange={setIsViewDialogOpen}>
|
||||
<DialogContent className="max-h-[90vh] w-full max-w-[95vw] overflow-y-auto lg:max-w-[1200px] dark:border-gray-700 dark:bg-gray-900">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("contentManagement.viewTemplate")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedTemplate && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{t("common.name")}
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{localizedName(selectedTemplate.name)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{t("contentManagement.sincerelyText")}
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
{selectedTemplate.sincerelyText || t("common.notAvailable")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600 dark:text-gray-300">
|
||||
{t("contentManagement.body")}
|
||||
</label>
|
||||
<div
|
||||
className="mt-1 max-h-60 overflow-y-auto rounded-md border bg-gray-50 p-3 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
dangerouslySetInnerHTML={{ __html: selectedTemplate.body }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setIsViewDialogOpen(false)}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Adopt Dialog */}
|
||||
<Dialog open={isAdoptDialogOpen} onOpenChange={setIsAdoptDialogOpen}>
|
||||
<DialogContent className="max-w-md dark:border-gray-700 dark:bg-gray-900">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("contentManagement.adoptTemplate", "Adopt Template")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("contentManagement.selectHeader", "Select Header")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedHeaderId}
|
||||
onValueChange={setSelectedHeaderId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"contentManagement.selectHeader",
|
||||
"Select Header",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{headersResponse?.data?.items?.map((header: any) => (
|
||||
<SelectItem key={header.id} value={header.id}>
|
||||
{localizedName(header.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("contentManagement.selectFooter", "Select Footer")}
|
||||
</label>
|
||||
<Select
|
||||
value={selectedFooterId}
|
||||
onValueChange={setSelectedFooterId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"contentManagement.selectFooter",
|
||||
"Select Footer",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{footersResponse?.data?.items?.map((footer: any) => (
|
||||
<SelectItem key={footer.id} value={footer.id}>
|
||||
{localizedName(footer.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsAdoptDialogOpen(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdopt}
|
||||
disabled={
|
||||
isAdoptingTemplate || !selectedHeaderId || !selectedFooterId
|
||||
}
|
||||
>
|
||||
{isAdoptingTemplate
|
||||
? t("common.adopting", "Adopting...")
|
||||
: t("common.adopt", "Adopt")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={setIsDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("contentManagement.deleteTemplate")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("contentManagement.deleteTemplateConfirm", {
|
||||
name: deleteTemplateName,
|
||||
})
|
||||
// Fallback if resources still use the legacy `{name}` placeholder.
|
||||
.replace("{{name}}", deleteTemplateName)
|
||||
.replace("{name}", deleteTemplateName)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isDeleting ? t("common.deleting") : t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LetterTemplatesTable;
|
||||
@@ -0,0 +1,653 @@
|
||||
// PrefixManagement.tsx
|
||||
import React, { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardHeader, CardTitle } from "@/shared/common/ui/card";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { usePositions } from "@/user-management/hooks/usePosition";
|
||||
import { t } from "i18next";
|
||||
import { PrefixSuffixTable } from "./PrefixSuffixTable";
|
||||
import { PrefixModal } from "./PrefixModal";
|
||||
import {
|
||||
useCCPrefixesList,
|
||||
useCCSuffixesList,
|
||||
useDeletePrefix,
|
||||
useDeleteSuffix,
|
||||
usePositionPrefixesList,
|
||||
usePrefixesList,
|
||||
useSuffixesList,
|
||||
} from "@/user-management/hooks/usePrefixSuffixes";
|
||||
import {
|
||||
useGetReferenceNumbers,
|
||||
} from "@/shared/hooks/useReferenceNumberPrefixes";
|
||||
import { useDeleteReferenceNumber } from "@/shared/hooks/useReferenceNumberPrefixes";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import { toast } from "sonner";
|
||||
import { TagBasedReferenceNumbers } from "./TagBasedReferenceNumbers";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
|
||||
export type PrefixTabType =
|
||||
| "internal"
|
||||
| "external"
|
||||
| "internal_memo"
|
||||
| "branch"
|
||||
| "tag-based-reference"
|
||||
| "referenceNumber"
|
||||
| "positionPrefix";
|
||||
|
||||
const tabs: { id: PrefixTabType; label: string }[] = [
|
||||
{ id: "internal", label: "nav.internal" },
|
||||
{ id: "external", label: "nav.external" },
|
||||
{ id: "internal_memo", label: "nav.internal_memo" },
|
||||
{ id: "branch", label: "nav.branch" },
|
||||
{ id: "tag-based-reference", label: "nav.tagBasedReference" },
|
||||
{ id: "referenceNumber", label: "userRecord.Reference" },
|
||||
{ id: "positionPrefix", label: "contentManagement.positionPrefixes" },
|
||||
];
|
||||
|
||||
interface PrefixManagementProps {
|
||||
unitId: string;
|
||||
initialTab?: PrefixTabType;
|
||||
}
|
||||
|
||||
const getDefaultCardForTab = (tab: PrefixTabType): string => {
|
||||
if (tab === "positionPrefix") return "internalPrefix";
|
||||
if (tab === "referenceNumber") return "reference";
|
||||
return "prefix";
|
||||
};
|
||||
|
||||
export const PrefixAndSuffix = ({
|
||||
unitId,
|
||||
initialTab = "internal",
|
||||
}: PrefixManagementProps) => {
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const [activeTab, setActiveTab] = useState<PrefixTabType>(initialTab);
|
||||
const [activeCard, setActiveCard] = useState<string>(
|
||||
getDefaultCardForTab(initialTab),
|
||||
);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalConfig, setModalConfig] = useState<{
|
||||
cardType: string;
|
||||
recordType: PrefixTabType;
|
||||
editingItem?: any;
|
||||
} | null>(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: deletePrefix } = useDeletePrefix();
|
||||
const { mutateAsync: deleteSuffix } = useDeleteSuffix();
|
||||
const { mutateAsync: deleteReferenceNumber, isPending: isDeletingReference } =
|
||||
useDeleteReferenceNumber();
|
||||
const [pendingReferenceDelete, setPendingReferenceDelete] = useState<{
|
||||
id: string;
|
||||
type: string;
|
||||
} | null>(null);
|
||||
|
||||
// Fetch positions for the position prefix tab
|
||||
const { usePositionListByUnitId } = usePositions();
|
||||
const { data: positionsResponse } = usePositionListByUnitId(unitId, {
|
||||
take: 1000,
|
||||
skip: 0,
|
||||
});
|
||||
const positions = positionsResponse?.items || [];
|
||||
|
||||
// Update active tab when the sidebar sub‑menu changes
|
||||
useEffect(() => {
|
||||
setActiveTab(initialTab);
|
||||
setActiveCard(getDefaultCardForTab(initialTab));
|
||||
}, [initialTab]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveCard(getDefaultCardForTab(activeTab));
|
||||
}, [activeTab]);
|
||||
|
||||
const handleAddClick = (cardType: string) => {
|
||||
setModalConfig({ cardType, recordType: activeTab });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
setModalConfig({
|
||||
cardType: item.type, // e.g., "prefix", "suffix", "reference"
|
||||
recordType: activeTab,
|
||||
editingItem: item,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, type: string, recordType: string) => {
|
||||
console.log("Delete requested for:", { id, type, recordType });
|
||||
const isReferenceType =
|
||||
type === "reference" ||
|
||||
type === "externalReference" ||
|
||||
type === "internalMemoReference";
|
||||
|
||||
if (isReferenceType) {
|
||||
setPendingReferenceDelete({ id, type });
|
||||
return;
|
||||
}
|
||||
|
||||
const isPrefixType =
|
||||
type === "prefix" ||
|
||||
type === "prefixCC" ||
|
||||
type === "internalPrefix" ||
|
||||
type === "externalPrefix" ||
|
||||
type === "internalMemoPrefix";
|
||||
const isForCC = type === "prefixCC" || type === "suffixCC";
|
||||
const resolvedRecordTypeKey =
|
||||
recordType ||
|
||||
(type === "internalPrefix"
|
||||
? "internal"
|
||||
: type === "externalPrefix"
|
||||
? "external"
|
||||
: type === "internalMemoPrefix"
|
||||
? "internal_memo"
|
||||
: activeTab);
|
||||
|
||||
try {
|
||||
if (isPrefixType) {
|
||||
await deletePrefix({
|
||||
id,
|
||||
unitId,
|
||||
recordTypeKey: resolvedRecordTypeKey,
|
||||
isForCC,
|
||||
});
|
||||
|
||||
if (activeTab === "positionPrefix") {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["prefixes-by-position", unitId, resolvedRecordTypeKey],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await deleteSuffix({
|
||||
id,
|
||||
unitId,
|
||||
recordTypeKey: resolvedRecordTypeKey,
|
||||
isForCC,
|
||||
});
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["prefixSuffix"] });
|
||||
toast.success(t("contentManagement.deleted"));
|
||||
} catch (error) {
|
||||
void handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReferenceDelete = async () => {
|
||||
if (!pendingReferenceDelete) return;
|
||||
if (!pendingReferenceDelete.id) {
|
||||
toast.error(
|
||||
t(
|
||||
"prefixes.deleteMissingId",
|
||||
"Unable to delete: missing configuration id.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const sequenceType =
|
||||
pendingReferenceDelete.type === "externalReference"
|
||||
? "external"
|
||||
: pendingReferenceDelete.type === "internalMemoReference"
|
||||
? "internal_memo"
|
||||
: "internal";
|
||||
try {
|
||||
await deleteReferenceNumber({
|
||||
unitId,
|
||||
id: pendingReferenceDelete.id,
|
||||
payload: { recordSequenceTypes: [sequenceType] },
|
||||
});
|
||||
setPendingReferenceDelete(null);
|
||||
toast.success(t("contentManagement.deleted"));
|
||||
} catch (error) {
|
||||
void handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setModalOpen(false);
|
||||
setModalConfig(null);
|
||||
};
|
||||
|
||||
const handleModalSuccess = () => {
|
||||
const targetCardType = modalConfig?.cardType ?? activeCard;
|
||||
const targetTab = modalConfig?.recordType ?? activeTab;
|
||||
|
||||
const targetIsCC =
|
||||
targetCardType === "prefixCC" || targetCardType === "suffixCC";
|
||||
const targetIsPrefix =
|
||||
targetCardType === "prefix" ||
|
||||
targetCardType === "prefixCC" ||
|
||||
targetCardType === "internalPrefix" ||
|
||||
targetCardType === "externalPrefix" ||
|
||||
targetCardType === "internalMemoPrefix" ||
|
||||
targetTab === "referenceNumber";
|
||||
|
||||
const targetRecordTypeKey =
|
||||
targetTab === "referenceNumber"
|
||||
? targetCardType
|
||||
: targetTab === "positionPrefix"
|
||||
? targetCardType === "internalPrefix"
|
||||
? "internal"
|
||||
: targetCardType === "internalMemoPrefix"
|
||||
? "internal_memo"
|
||||
: "external"
|
||||
: targetTab;
|
||||
|
||||
if (targetIsPrefix) {
|
||||
if (targetTab === "positionPrefix") {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["prefixes-by-position", unitId, targetRecordTypeKey],
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["prefixes", unitId, targetRecordTypeKey, { cc: targetIsCC }],
|
||||
});
|
||||
} else {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["suffixes", unitId, targetRecordTypeKey, { cc: targetIsCC }],
|
||||
});
|
||||
}
|
||||
|
||||
// Keep old key invalidation for legacy consumers.
|
||||
queryClient.invalidateQueries({ queryKey: ["prefixSuffix"] });
|
||||
handleModalClose();
|
||||
};
|
||||
|
||||
// Define cards for the active tab
|
||||
const getCards = () => {
|
||||
if (activeTab === "positionPrefix") {
|
||||
return [
|
||||
{ id: "internalPrefix", label: "contentManagement.internalPrefix" },
|
||||
{ id: "externalPrefix", label: "contentManagement.externalPrefix" },
|
||||
{
|
||||
id: "internalMemoPrefix",
|
||||
label: "contentManagement.internalMemoPrefix",
|
||||
},
|
||||
];
|
||||
}
|
||||
if (activeTab === "referenceNumber") {
|
||||
return [
|
||||
{ id: "reference", label: "contentManagement.reference" },
|
||||
{
|
||||
id: "externalReference",
|
||||
label: "contentManagement.externalReference",
|
||||
},
|
||||
{
|
||||
id: "internalMemoReference",
|
||||
label: "contentManagement.internalMemoReference",
|
||||
},
|
||||
];
|
||||
}
|
||||
// internal, external, internal_memo
|
||||
return [
|
||||
{ id: "prefix", label: "contentManagement.prefix" },
|
||||
{ id: "suffix", label: "contentManagement.suffix" },
|
||||
{ id: "prefixCC", label: "contentManagement.prefixCC" },
|
||||
{ id: "suffixCC", label: "contentManagement.suffixCC" },
|
||||
];
|
||||
};
|
||||
|
||||
const cards = getCards();
|
||||
useEffect(() => {
|
||||
if (!cards.some((card) => card.id === activeCard)) {
|
||||
setActiveCard(cards[0]?.id || getDefaultCardForTab(activeTab));
|
||||
}
|
||||
}, [cards, activeCard, activeTab]);
|
||||
|
||||
const isCC = activeCard === "prefixCC" || activeCard === "suffixCC";
|
||||
const isReferenceTab = activeTab === "referenceNumber";
|
||||
const isPositionPrefixTab = activeTab === "positionPrefix";
|
||||
const isTagBasedTab = activeTab === "tag-based-reference";
|
||||
const isPrefix =
|
||||
activeCard === "prefix" ||
|
||||
activeCard === "prefixCC" ||
|
||||
activeCard === "internalPrefix" ||
|
||||
activeCard === "externalPrefix" ||
|
||||
activeCard === "internalMemoPrefix" ||
|
||||
isReferenceTab;
|
||||
|
||||
const positionRecordTypeKey =
|
||||
activeCard === "internalPrefix"
|
||||
? "internal"
|
||||
: activeCard === "externalPrefix"
|
||||
? "external"
|
||||
: activeCard === "internalMemoPrefix"
|
||||
? "internal_memo"
|
||||
: "";
|
||||
const recordTypeKey =
|
||||
activeTab === "positionPrefix" ? positionRecordTypeKey : activeTab;
|
||||
|
||||
const {
|
||||
data: referenceNumbersData,
|
||||
isLoading: isLoadingReferenceNumbers,
|
||||
error: referenceNumbersError,
|
||||
} = useGetReferenceNumbers(isReferenceTab ? unitId : "");
|
||||
|
||||
const {
|
||||
data: prefixesData,
|
||||
isLoading: isLoadingPrefixes,
|
||||
error: prefixesError,
|
||||
} = usePrefixesList(
|
||||
unitId,
|
||||
isPrefix &&
|
||||
!isCC &&
|
||||
!isReferenceTab &&
|
||||
!isPositionPrefixTab &&
|
||||
!isTagBasedTab
|
||||
? recordTypeKey
|
||||
: "",
|
||||
0,
|
||||
1000,
|
||||
);
|
||||
const {
|
||||
data: positionPrefixesData,
|
||||
isLoading: isLoadingPositionPrefixes,
|
||||
error: positionPrefixesError,
|
||||
} = usePositionPrefixesList(
|
||||
unitId,
|
||||
isPositionPrefixTab ? positionRecordTypeKey : "",
|
||||
0,
|
||||
1000,
|
||||
);
|
||||
const {
|
||||
data: ccPrefixesData,
|
||||
isLoading: isLoadingCCPrefixes,
|
||||
error: ccPrefixesError,
|
||||
} = useCCPrefixesList(
|
||||
unitId,
|
||||
isPrefix && isCC && !isReferenceTab ? recordTypeKey : "",
|
||||
0,
|
||||
1000,
|
||||
);
|
||||
const {
|
||||
data: suffixesData,
|
||||
isLoading: isLoadingSuffixes,
|
||||
error: suffixesError,
|
||||
} = useSuffixesList(
|
||||
unitId,
|
||||
!isPrefix && !isCC && !isReferenceTab ? recordTypeKey : "",
|
||||
0,
|
||||
1000,
|
||||
);
|
||||
const {
|
||||
data: ccSuffixesData,
|
||||
isLoading: isLoadingCCSuffixes,
|
||||
error: ccSuffixesError,
|
||||
} = useCCSuffixesList(
|
||||
unitId,
|
||||
!isPrefix && isCC && !isReferenceTab ? recordTypeKey : "",
|
||||
0,
|
||||
1000,
|
||||
);
|
||||
|
||||
const firstQueryError = useMemo(
|
||||
() =>
|
||||
[
|
||||
referenceNumbersError,
|
||||
positionPrefixesError,
|
||||
prefixesError,
|
||||
ccPrefixesError,
|
||||
suffixesError,
|
||||
ccSuffixesError,
|
||||
].find(Boolean),
|
||||
[
|
||||
referenceNumbersError,
|
||||
positionPrefixesError,
|
||||
prefixesError,
|
||||
ccPrefixesError,
|
||||
suffixesError,
|
||||
ccSuffixesError,
|
||||
],
|
||||
);
|
||||
|
||||
const lastHandledErrorRef = useRef<unknown>(null);
|
||||
useEffect(() => {
|
||||
if (!firstQueryError || firstQueryError === lastHandledErrorRef.current) {
|
||||
return;
|
||||
}
|
||||
lastHandledErrorRef.current = firstQueryError;
|
||||
void handleError(firstQueryError);
|
||||
}, [firstQueryError, handleError]);
|
||||
const referenceNumbers = referenceNumbersData?.data || [];
|
||||
const referenceNumberPrefix = referenceNumbers?.find(
|
||||
(rn: any) => rn.name === "referenceNumberPrefix",
|
||||
);
|
||||
|
||||
const externalReferenceNumberPrefix = referenceNumbers?.find(
|
||||
(rn: any) => rn.name === "externalReferenceNumberPrefix",
|
||||
);
|
||||
|
||||
const internalMemoReferenceNumberPrefix = referenceNumbers?.find(
|
||||
(rn: any) => rn.name === "internalMemoReferenceNumberPrefix",
|
||||
);
|
||||
|
||||
const getAmValue = (value: any): string => {
|
||||
if (!value) return "";
|
||||
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed?.am ?? value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
return value?.am ?? "";
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
const branchItems = [
|
||||
{
|
||||
id: unitId,
|
||||
type: "reference",
|
||||
recordTypeKey: "reference",
|
||||
isForCC: false,
|
||||
name: {
|
||||
am: getAmValue(referenceNumberPrefix?.number),
|
||||
en: getAmValue(referenceNumberPrefix?.number?.en),
|
||||
},
|
||||
count: referenceNumberPrefix?.count ?? 0,
|
||||
sequenceId: referenceNumberPrefix?.sequenceId,
|
||||
},
|
||||
{
|
||||
id: unitId,
|
||||
type: "externalReference",
|
||||
recordTypeKey: "externalReference",
|
||||
isForCC: false,
|
||||
name: {
|
||||
am: getAmValue(externalReferenceNumberPrefix?.number?.am),
|
||||
en: getAmValue(externalReferenceNumberPrefix?.number?.en),
|
||||
},
|
||||
count: externalReferenceNumberPrefix?.count ?? 0,
|
||||
sequenceId: externalReferenceNumberPrefix?.sequenceId,
|
||||
},
|
||||
{
|
||||
id: unitId,
|
||||
type: "internalMemoReference",
|
||||
recordTypeKey: "internalMemoReference",
|
||||
isForCC: false,
|
||||
name: {
|
||||
am: getAmValue(internalMemoReferenceNumberPrefix?.number?.am),
|
||||
en: getAmValue(internalMemoReferenceNumberPrefix?.number?.en),
|
||||
},
|
||||
count: internalMemoReferenceNumberPrefix?.count ?? 0,
|
||||
sequenceId: internalMemoReferenceNumberPrefix?.sequenceId,
|
||||
},
|
||||
].filter((item) => item.type === activeCard);
|
||||
|
||||
const activeResponse = isPositionPrefixTab
|
||||
? positionPrefixesData
|
||||
: isPrefix
|
||||
? isCC
|
||||
? ccPrefixesData
|
||||
: prefixesData
|
||||
: isCC
|
||||
? ccSuffixesData
|
||||
: suffixesData;
|
||||
|
||||
const items = isReferenceTab
|
||||
? branchItems
|
||||
: activeResponse?.data?.items?.map((item: any) => ({
|
||||
...item,
|
||||
type:
|
||||
activeTab === "positionPrefix"
|
||||
? item.recordTypeKey === "internal"
|
||||
? "internalPrefix"
|
||||
: item.recordTypeKey === "internal_memo"
|
||||
? "internalMemoPrefix"
|
||||
: "externalPrefix"
|
||||
: isPrefix
|
||||
? isCC
|
||||
? "prefixCC"
|
||||
: "prefix"
|
||||
: isCC
|
||||
? "suffixCC"
|
||||
: "suffix",
|
||||
isForCC: isCC || item.isForCC,
|
||||
})) || [];
|
||||
|
||||
const isLoading =
|
||||
isLoadingReferenceNumbers ||
|
||||
isLoadingPositionPrefixes ||
|
||||
isLoadingPrefixes ||
|
||||
isLoadingCCPrefixes ||
|
||||
isLoadingSuffixes ||
|
||||
isLoadingCCSuffixes;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id);
|
||||
setActiveCard(getDefaultCardForTab(tab.id));
|
||||
}}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-purple-600 text-purple-600 dark:text-purple-400"
|
||||
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
{t(tab.label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
{activeTab === "tag-based-reference" ? (
|
||||
<TagBasedReferenceNumbers unitId={unitId} className="w-full" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{cards.map((card) => (
|
||||
<Card
|
||||
key={card.id}
|
||||
onClick={() => setActiveCard(card.id)}
|
||||
className={`inline-flex w-fit min-w-[260px] max-w-full shadow-sm hover:shadow-md transition-shadow cursor-pointer dark:bg-gray-800 dark:border-gray-700 ${
|
||||
activeCard === card.id
|
||||
? "ring-2 ring-purple-600 dark:ring-purple-400"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<CardHeader className="pb-2 items-center">
|
||||
<CardTitle className="text-sm font-medium text-gray-700 dark:text-gray-200 text-center whitespace-nowrap">
|
||||
{t(card.label)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button onClick={() => handleAddClick(activeCard)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("contentManagement.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<PrefixSuffixTable
|
||||
items={items}
|
||||
isLoading={isLoading}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
showPositionColumn={activeTab === "positionPrefix"}
|
||||
showCountColumn={
|
||||
activeTab === "positionPrefix" || activeTab === "referenceNumber"
|
||||
}
|
||||
positions={positions}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
{modalOpen && modalConfig && (
|
||||
<PrefixModal
|
||||
open={modalOpen}
|
||||
onClose={handleModalClose}
|
||||
onSuccess={handleModalSuccess}
|
||||
unitId={unitId}
|
||||
cardType={modalConfig.cardType}
|
||||
recordType={modalConfig.recordType}
|
||||
editingItem={modalConfig.editingItem}
|
||||
positions={positions}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={!!pendingReferenceDelete}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isDeletingReference) {
|
||||
setPendingReferenceDelete(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t(
|
||||
"prefixes.deleteConfirmTitle",
|
||||
"Delete reference number prefix?",
|
||||
)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(
|
||||
"prefixes.deleteConfirmDescription",
|
||||
"Are you sure you want to delete this prefix? This action cannot be undone.",
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeletingReference}>
|
||||
{t("common.cancel") || "Cancel"}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmReferenceDelete}
|
||||
disabled={isDeletingReference}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{isDeletingReference
|
||||
? t("contentManagement.loading", "Loading...")
|
||||
: t("common.delete", "Delete") || "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +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;
|
||||
@@ -0,0 +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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +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>
|
||||
);
|
||||
};
|
||||
|
||||
export default SealCard;
|
||||
@@ -0,0 +1,578 @@
|
||||
// components/tag-based-reference-numbers.tsx
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Plus, Trash2, Loader2, AlertCircle, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useRecordTagPrefixes } from "@/user-management/hooks/useRecordTagPrefixes";
|
||||
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { prefixSuffixService } from "@/user-management/services/api/prefixSuffixService";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { RecordTagSelector } from "./RecordTagSelector";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/shared/common/ui/table";
|
||||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/shared/common/ui/pagination";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { CreatePrefixPayload } from "@/user-management/services/api/prefixSuffixService";
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────
|
||||
|
||||
export interface TagBasedReferenceNumbersProps {
|
||||
unitId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface PrefixItem {
|
||||
id: string;
|
||||
prefix: string;
|
||||
suffix?: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ─── Component ─────────────────────────────────────────
|
||||
|
||||
export function TagBasedReferenceNumbers({
|
||||
unitId,
|
||||
className,
|
||||
}: TagBasedReferenceNumbersProps) {
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedTagId, setSelectedTagId] = React.useState<string>("");
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [countPopup, setCountPopup] = React.useState<{
|
||||
itemId: string | null;
|
||||
currentCount: number;
|
||||
} | null>(null);
|
||||
const [countValue, setCountValue] = React.useState("");
|
||||
const [currentPage, setCurrentPage] = React.useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
const skip = (currentPage - 1) * pageSize;
|
||||
|
||||
const { recordTagsList, isLoadingRecordTagsList } = useRecordTags({ unitId });
|
||||
|
||||
const {
|
||||
prefixes,
|
||||
total,
|
||||
isLoadingPrefixes,
|
||||
isErrorPrefixes,
|
||||
createPrefix,
|
||||
isCreatingPrefix,
|
||||
deletePrefix,
|
||||
isDeletingPrefix,
|
||||
} = useRecordTagPrefixes({
|
||||
unitId,
|
||||
recordTagId: selectedTagId || undefined,
|
||||
skip,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
// Reset page when tag changes
|
||||
React.useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [selectedTagId]);
|
||||
|
||||
// Get selected tag details for display
|
||||
const selectedTag = React.useMemo(() => {
|
||||
return recordTagsList?.items?.find((tag) => tag.id === selectedTagId);
|
||||
}, [recordTagsList, selectedTagId]);
|
||||
|
||||
// Handle add prefix
|
||||
const handleAddPrefix = React.useCallback(
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const nameAm = (formData.get("nameAm") as string)?.trim();
|
||||
const nameEn = (formData.get("nameEn") as string)?.trim();
|
||||
|
||||
if (!nameAm || !nameEn) return;
|
||||
|
||||
const payload: CreatePrefixPayload = {
|
||||
name: { am: nameAm, en: nameEn },
|
||||
unitId,
|
||||
isForCC: false,
|
||||
recordTagId: selectedTagId || undefined,
|
||||
};
|
||||
|
||||
createPrefix(payload, {
|
||||
onSuccess: () => {
|
||||
setIsAddDialogOpen(false);
|
||||
},
|
||||
});
|
||||
},
|
||||
[createPrefix, selectedTagId, unitId],
|
||||
);
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = React.useCallback(() => {
|
||||
if (deleteTargetId) {
|
||||
deletePrefix(deleteTargetId, {
|
||||
onSuccess: () => setDeleteTargetId(null),
|
||||
});
|
||||
}
|
||||
}, [deletePrefix, deleteTargetId]);
|
||||
|
||||
const getCount = (item: any): number =>
|
||||
item.recordSequences?.[0]?.count ?? item.count ?? 0;
|
||||
|
||||
const getSequenceId = (item: any): string | null =>
|
||||
item.recordSequences?.[0]?.id ?? item.sequenceId ?? null;
|
||||
|
||||
const openCountPopup = (item: any) => {
|
||||
console.log("[TagBasedRef] prefix item:", item);
|
||||
const currentCount = getCount(item);
|
||||
const sequenceId = getSequenceId(item);
|
||||
setCountPopup({ itemId: sequenceId, currentCount });
|
||||
setCountValue(String(currentCount));
|
||||
};
|
||||
|
||||
const closeCountPopup = () => {
|
||||
setCountPopup(null);
|
||||
setCountValue("");
|
||||
queryClient.invalidateQueries({ queryKey: ["recordTagPrefixes", unitId, selectedTagId] });
|
||||
};
|
||||
|
||||
const handleCountSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!countPopup || !countPopup.itemId) return;
|
||||
const newCount = parseInt(countValue, 10);
|
||||
if (isNaN(newCount) || newCount < 0) return;
|
||||
prefixSuffixService.updateCount(countPopup.itemId, newCount).then(() => {
|
||||
closeCountPopup();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-6", className)}>
|
||||
{/* ─── Header Section: Tag Selector + Add Button ─── */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-end gap-4">
|
||||
<div className="flex-1 w-full sm:w-auto space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("nav.selectTag") || "Select Record Tag"}
|
||||
</Label>
|
||||
<RecordTagSelector
|
||||
unitId={unitId}
|
||||
selectedTagIds={selectedTagId ? [selectedTagId] : []}
|
||||
onChange={(ids) => setSelectedTagId(ids[0] || "")}
|
||||
placeholder={
|
||||
t("nav.selectTag") || "Choose a tag to view prefixes..."
|
||||
}
|
||||
multiple={false}
|
||||
disabled={isLoadingRecordTagsList}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
disabled={!selectedTagId || isLoadingPrefixes}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("contentManagement.addPrefix") || "Add Prefix"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("contentManagement.addPrefix") || "Add New Prefix"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
id="add-prefix-form"
|
||||
onSubmit={handleAddPrefix}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nameAm" className="dark:text-gray-200">
|
||||
{t("contentManagement.amharicName")} *
|
||||
</Label>
|
||||
<Input
|
||||
id="nameAm"
|
||||
name="nameAm"
|
||||
placeholder={t("contentManagement.amharicName")}
|
||||
required
|
||||
autoFocus
|
||||
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"
|
||||
name="nameEn"
|
||||
placeholder={t("contentManagement.englishName")}
|
||||
required
|
||||
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsAddDialogOpen(false)}
|
||||
>
|
||||
{t("common.cancel") || "Cancel"}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="add-prefix-form"
|
||||
disabled={isCreatingPrefix}
|
||||
>
|
||||
{isCreatingPrefix && (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
)}
|
||||
{t("common.save") || "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* ─── Selected Tag Info ─── */}
|
||||
{selectedTag && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Showing prefixes for:</span>
|
||||
<Badge variant="outline">{localizedName(selectedTag.name)}</Badge>
|
||||
<span className="text-xs">({total} items)</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Table Section ─── */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[80px]">
|
||||
{t("common.no") || "#"}
|
||||
</TableHead>
|
||||
<TableHead>{t("contentManagement.amharicName")}</TableHead>
|
||||
<TableHead>{t("contentManagement.englishName")}</TableHead>
|
||||
<TableHead>
|
||||
{t("contentManagement.createdAt") || "Created"}
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px]">
|
||||
{t("contentManagement.count") || "Count"}
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px] text-right">
|
||||
{t("common.actions") || "Actions"}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{/* Loading State */}
|
||||
{isLoadingPrefixes && (
|
||||
<>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={`skeleton-${i}`}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-8 w-8 ml-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{isErrorPrefixes && !isLoadingPrefixes && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-32 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-destructive">
|
||||
<AlertCircle className="h-8 w-8" />
|
||||
<p className="text-sm">
|
||||
{t("common.errorLoading") || "Failed to load prefixes"}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
{t("common.retry") || "Retry"}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{/* Empty States */}
|
||||
{!isLoadingPrefixes && !isErrorPrefixes && (
|
||||
<>
|
||||
{!selectedTagId && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-32 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<span className="text-2xl">🏷️</span>
|
||||
<p className="text-sm">
|
||||
{t("nav.selectTag") ||
|
||||
"Select a tag above to view its prefixes"}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{selectedTagId && prefixes.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-32 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<Plus className="h-8 w-8 opacity-50" />
|
||||
<p className="text-sm">
|
||||
{t("nav.emptyState") ||
|
||||
"No prefixes found for this tag"}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
>
|
||||
{t("nav.addNew") || "Add your first prefix"}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{/* Data Rows */}
|
||||
{prefixes.map((prefix, index: number) => {
|
||||
const count = getCount(prefix);
|
||||
return (
|
||||
<TableRow key={prefix.id} className="group">
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{skip + index + 1}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{prefix.name?.am || (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{prefix.name?.en || (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{new Date(prefix.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
onClick={() => openCountPopup(prefix)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 underline cursor-pointer text-sm"
|
||||
>
|
||||
{count}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
onClick={() => setDeleteTargetId(prefix.id)}
|
||||
disabled={isDeletingPrefix}
|
||||
title="Delete this prefix"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* ─── Pagination ─── */}
|
||||
{totalPages > 1 && (
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
className={cn(
|
||||
currentPage === 1 && "pointer-events-none opacity-50",
|
||||
)}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
isActive={currentPage === page}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
>
|
||||
{page}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() =>
|
||||
setCurrentPage((p) => Math.min(totalPages, p + 1))
|
||||
}
|
||||
className={cn(
|
||||
currentPage === totalPages &&
|
||||
"pointer-events-none opacity-50",
|
||||
)}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
|
||||
{/* ─── 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>
|
||||
{!countPopup.itemId && (
|
||||
<p className="text-xs text-red-500">
|
||||
No sequence found for this prefix. Use it in a record first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="outline" onClick={closeCountPopup}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!countPopup.itemId}>
|
||||
{t("common.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Delete Confirmation ─── */}
|
||||
<AlertDialog
|
||||
open={!!deleteTargetId}
|
||||
onOpenChange={(open) => !open && setDeleteTargetId(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("prefixes.deleteConfirmTitle") || "Delete Tag-Based Reference Prefix?"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("prefixes.deleteConfirmDescription") ||
|
||||
"Are you sure you want to delete this prefix? This action cannot be undone and will permanently remove this prefix from the system."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeletingPrefix}>
|
||||
{t("common.cancel") || "Cancel"}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeletingPrefix}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeletingPrefix && (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
)}
|
||||
{t("common.delete") || "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +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} />
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +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;
|
||||
}
|
||||
Reference in New Issue
Block a user