user management ui

This commit is contained in:
yaschalew
2026-07-10 10:41:48 +03:00
parent dcb2d98503
commit 28a20923ff
595 changed files with 0 additions and 0 deletions

View File

@@ -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;

View File

@@ -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} />,
},
];

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 submenu 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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} />
),
},
];
};

View File

@@ -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;
}