mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
fix ui
This commit is contained in:
@@ -1,221 +1,221 @@
|
||||
import React, { useState } from "react";
|
||||
import { usePendingUsers } from "@/user-management/userManagement/hooks/usePendingUsersHook";
|
||||
import { toast } from "sonner";
|
||||
import { Check, X, Users, Search, RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
const ReviewAllPendingUsers = () => {
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const { pendingUsers, loadingPendingUsers, approveMutation, rejectMutation } =
|
||||
usePendingUsers({ take: 50, skip: 0 });
|
||||
|
||||
const handleApprove = (id: string) => {
|
||||
approveMutation.mutate(id, {
|
||||
onSuccess: () => toast.success(t("pendingUsers.employeeApprovedSuccess")),
|
||||
onError: (error) => handleError(error),
|
||||
});
|
||||
};
|
||||
|
||||
const handleReject = (id: string) => {
|
||||
rejectMutation.mutate(id, {
|
||||
onSuccess: () => toast.success(t("pendingUsers.employeeRejectedSuccess")),
|
||||
onError: (error) => handleError(error),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Integrate search with your API if needed
|
||||
};
|
||||
|
||||
// Loading skeleton (full width)
|
||||
if (loadingPendingUsers) {
|
||||
return (
|
||||
<div className="p-6 w-full">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="h-6 w-24 bg-gray-200 rounded animate-pulse"></div>
|
||||
</div>
|
||||
{/* Search bar skeleton */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<div className="flex-1 h-10 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="w-20 h-10 bg-gray-200 rounded animate-pulse"></div>
|
||||
</div>
|
||||
{/* Table skeleton */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
{[
|
||||
t("pendingUsers.name"),
|
||||
t("pendingUsers.email"),
|
||||
t("pendingUsers.positions"),
|
||||
t("pendingUsers.actions"),
|
||||
].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="h-4 w-40 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="h-4 w-48 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 w-20 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
<div className="h-8 w-20 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (!pendingUsers?.items?.length) {
|
||||
return (
|
||||
<div className="p-6 w-full">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Users className="h-6 w-6 text-blue-600" />
|
||||
{t("pendingUsers.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-12 text-center mt-6">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="bg-primary-100 p-3 rounded-full">
|
||||
<Users className="h-8 w-8 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
{t("pendingUsers.noPendingApprovals")}
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
{t("pendingUsers.allRequestsReviewed")}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t("pendingUsers.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 w-full">
|
||||
{/* Header with count */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Users className="h-6 w-6 text-blue-600" />
|
||||
{t("pendingUsers.title")}
|
||||
</h1>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
|
||||
{pendingUsers.count} {t("pendingUsers.pending")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table - full width */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden mt-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t("pendingUsers.name")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t("pendingUsers.email")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t("pendingUsers.positions")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t("pendingUsers.actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{pendingUsers.items.map((emp: any) => (
|
||||
<tr
|
||||
key={emp.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{emp.name?.en}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{emp.user?.email}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{emp.employeePositions
|
||||
?.map((pos: any) => pos.position?.name?.en)
|
||||
.join(", ") || "—"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-center">
|
||||
<div className="flex justify-center gap-2">
|
||||
<button
|
||||
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
disabled={approveMutation.isPending}
|
||||
onClick={() => handleApprove(emp.id)}>
|
||||
<Check className="h-3.5 w-3.5 mr-1" />
|
||||
{t("pendingUsers.approve")}
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
disabled={rejectMutation.isPending}
|
||||
onClick={() => handleReject(emp.id)}>
|
||||
<X className="h-3.5 w-3.5 mr-1" />
|
||||
{t("pendingUsers.reject")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer note */}
|
||||
<p className="text-xs text-gray-400 mt-4 text-center">
|
||||
{t("pendingUsers.showingUpTo")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReviewAllPendingUsers;
|
||||
import React, { useState } from "react";
|
||||
import { usePendingUsers } from "@/user-management/userManagement/hooks/usePendingUsersHook";
|
||||
import { toast } from "sonner";
|
||||
import { Check, X, Users, Search, RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
const ReviewAllPendingUsers = () => {
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const { pendingUsers, loadingPendingUsers, approveMutation, rejectMutation } =
|
||||
usePendingUsers({ take: 50, skip: 0 });
|
||||
|
||||
const handleApprove = (id: string) => {
|
||||
approveMutation.mutate(id, {
|
||||
onSuccess: () => toast.success(t("pendingUsers.employeeApprovedSuccess")),
|
||||
onError: (error) => handleError(error),
|
||||
});
|
||||
};
|
||||
|
||||
const handleReject = (id: string) => {
|
||||
rejectMutation.mutate(id, {
|
||||
onSuccess: () => toast.success(t("pendingUsers.employeeRejectedSuccess")),
|
||||
onError: (error) => handleError(error),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Integrate search with your API if needed
|
||||
};
|
||||
|
||||
// Loading skeleton (full width)
|
||||
if (loadingPendingUsers) {
|
||||
return (
|
||||
<div className="p-6 w-full">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="h-6 w-24 bg-gray-200 rounded animate-pulse"></div>
|
||||
</div>
|
||||
{/* Search bar skeleton */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<div className="flex-1 h-10 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="w-20 h-10 bg-gray-200 rounded animate-pulse"></div>
|
||||
</div>
|
||||
{/* Table skeleton */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
{[
|
||||
t("pendingUsers.name"),
|
||||
t("pendingUsers.email"),
|
||||
t("pendingUsers.positions"),
|
||||
t("pendingUsers.actions"),
|
||||
].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="h-4 w-40 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="h-4 w-48 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 w-20 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
<div className="h-8 w-20 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (!pendingUsers?.items?.length) {
|
||||
return (
|
||||
<div className="p-6 w-full">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Users className="h-6 w-6 text-blue-600" />
|
||||
{t("pendingUsers.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-100 p-12 text-center mt-6">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="bg-primary-100 p-3 rounded-full">
|
||||
<Users className="h-8 w-8 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
{t("pendingUsers.noPendingApprovals")}
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
{t("pendingUsers.allRequestsReviewed")}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t("pendingUsers.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 w-full">
|
||||
{/* Header with count */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Users className="h-6 w-6 text-blue-600" />
|
||||
{t("pendingUsers.title")}
|
||||
</h1>
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
|
||||
{pendingUsers.count} {t("pendingUsers.pending")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table - full width */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-100 dark:border-gray-700 overflow-hidden mt-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t("pendingUsers.name")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t("pendingUsers.email")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t("pendingUsers.positions")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t("pendingUsers.actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{pendingUsers.items.map((emp: any) => (
|
||||
<tr
|
||||
key={emp.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{emp.name?.en}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{emp.user?.email}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{emp.employeePositions
|
||||
?.map((pos: any) => pos.position?.name?.en)
|
||||
.join(", ") || "—"}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-center">
|
||||
<div className="flex justify-center gap-2">
|
||||
<button
|
||||
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
disabled={approveMutation.isPending}
|
||||
onClick={() => handleApprove(emp.id)}>
|
||||
<Check className="h-3.5 w-3.5 mr-1" />
|
||||
{t("pendingUsers.approve")}
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
disabled={rejectMutation.isPending}
|
||||
onClick={() => handleReject(emp.id)}>
|
||||
<X className="h-3.5 w-3.5 mr-1" />
|
||||
{t("pendingUsers.reject")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer note */}
|
||||
<p className="text-xs text-gray-400 mt-4 text-center">
|
||||
{t("pendingUsers.showingUpTo")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReviewAllPendingUsers;
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { ItemDTO, OrganizationUserDto, User } from "@/shared/dto/user/usersDto";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import ArchivedUserActionsCell from "./ArchivedUsers/ArchivedUserActions";
|
||||
import { format } from "date-fns/format";
|
||||
import { t } from "i18next";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
|
||||
|
||||
export const ArchivedUserColumnDefn: ColumnDef<ItemDTO>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: () => t("setting.Name"),
|
||||
cell: ({ row }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const localizedName = useLocalizedName();
|
||||
const name = row.original?.name;
|
||||
return <span>{localizedName(name)}</span>;
|
||||
},
|
||||
},
|
||||
// {
|
||||
// accessorKey: "username",
|
||||
// header: "Username",
|
||||
// cell: ({ row }) => {
|
||||
// const username = row.original.username;
|
||||
// return <span>{username}</span>;
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// accessorKey: "createdAt",
|
||||
// header: "Created At",
|
||||
// cell: ({ row }) => {
|
||||
// const date = row.original.createdAt;
|
||||
// return <span>{format(new Date(date), "MMM d, yyyy HH:mm")}</span>;
|
||||
// },
|
||||
// },
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: () => t("userRecord.Status"),
|
||||
cell: ({ row }) => {
|
||||
const status = row.original?.status;
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case "inactive":
|
||||
return "bg-red-100 text-red-600 hover:bg-red-100"; // red for inactive
|
||||
case "active":
|
||||
return "bg-primary-100 text-primary-600 hover:bg-primary-100"; // green for active
|
||||
default:
|
||||
return "bg-gray-100 text-gray-600 hover:bg-gray-100";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case "inactive":
|
||||
return "InActive";
|
||||
case "active":
|
||||
return "Active";
|
||||
|
||||
default:
|
||||
return "Not Available";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Badge
|
||||
className={`${getStatusColor(
|
||||
status
|
||||
)} rounded-full px-6 py-1 font-medium`}>
|
||||
{getStatusText(status)}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => t("userRecord.Actions"),
|
||||
cell: ({ row }) => <ArchivedUserActionsCell row={row.original} />,
|
||||
},
|
||||
];
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { ItemDTO, OrganizationUserDto, User } from "@/shared/dto/user/usersDto";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import ArchivedUserActionsCell from "./ArchivedUsers/ArchivedUserActions";
|
||||
import { format } from "date-fns/format";
|
||||
import { t } from "i18next";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
|
||||
|
||||
export const ArchivedUserColumnDefn: ColumnDef<ItemDTO>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: () => t("setting.Name"),
|
||||
cell: ({ row }) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const localizedName = useLocalizedName();
|
||||
const name = row.original?.name;
|
||||
return <span>{localizedName(name)}</span>;
|
||||
},
|
||||
},
|
||||
// {
|
||||
// accessorKey: "username",
|
||||
// header: "Username",
|
||||
// cell: ({ row }) => {
|
||||
// const username = row.original.username;
|
||||
// return <span>{username}</span>;
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// accessorKey: "createdAt",
|
||||
// header: "Created At",
|
||||
// cell: ({ row }) => {
|
||||
// const date = row.original.createdAt;
|
||||
// return <span>{format(new Date(date), "MMM d, yyyy HH:mm")}</span>;
|
||||
// },
|
||||
// },
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: () => t("userRecord.Status"),
|
||||
cell: ({ row }) => {
|
||||
const status = row.original?.status;
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case "inactive":
|
||||
return "bg-red-100 text-red-600 hover:bg-red-100"; // red for inactive
|
||||
case "active":
|
||||
return "bg-primary-100 text-primary-600 hover:bg-primary-100"; // green for active
|
||||
default:
|
||||
return "bg-gray-100 text-gray-600 hover:bg-gray-100";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case "inactive":
|
||||
return "InActive";
|
||||
case "active":
|
||||
return "Active";
|
||||
|
||||
default:
|
||||
return "Not Available";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Badge
|
||||
className={`${getStatusColor(
|
||||
status
|
||||
)} rounded-full px-6 py-1 font-medium`}>
|
||||
{getStatusText(status)}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => t("userRecord.Actions"),
|
||||
cell: ({ row }) => <ArchivedUserActionsCell row={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,78 +1,78 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import { useEmployeePositions } from "@/user-management/hooks/useEmployeePostions";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ActivateArchivedUserProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
|
||||
const ActivateArchivedUser: React.FC<ActivateArchivedUserProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
userId,
|
||||
}) => {
|
||||
const { activateUser, isActivatingUser } = useEmployeePositions();
|
||||
const [isActivatedUser, setIsActivatedUser] = useState(false);
|
||||
const {t} = useTranslation()
|
||||
const {handleError } = useErrorHandler(t)
|
||||
const onActviate = async () => {
|
||||
try {
|
||||
await activateUser({
|
||||
payload: userId,
|
||||
successCallback: () => {
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
setIsActivatedUser(true);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error activating user:", error);
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={onClose}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Activate Archived User?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to activate this archived user? This action will restore the user's access and data within the organization.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isActivatedUser}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onActviate}
|
||||
disabled={isActivatedUser}>
|
||||
{isActivatingUser && (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
)}
|
||||
Activate
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivateArchivedUser;
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import { useEmployeePositions } from "@/user-management/hooks/useEmployeePostions";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ActivateArchivedUserProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
|
||||
const ActivateArchivedUser: React.FC<ActivateArchivedUserProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
userId,
|
||||
}) => {
|
||||
const { activateUser, isActivatingUser } = useEmployeePositions();
|
||||
const [isActivatedUser, setIsActivatedUser] = useState(false);
|
||||
const {t} = useTranslation()
|
||||
const {handleError } = useErrorHandler(t)
|
||||
const onActviate = async () => {
|
||||
try {
|
||||
await activateUser({
|
||||
payload: userId,
|
||||
successCallback: () => {
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
setIsActivatedUser(true);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error activating user:", error);
|
||||
handleError(error);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={onClose}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Activate Archived User?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to activate this archived user? This action will restore the user's access and data within the organization.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isActivatedUser}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onActviate}
|
||||
disabled={isActivatedUser}>
|
||||
{isActivatingUser && (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
)}
|
||||
Activate
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivateArchivedUser;
|
||||
|
||||
@@ -1,104 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/shared/common/ui/dropdown-menu"; // Adjust if needed
|
||||
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { MoreVertical, Edit, Trash, UserPlus } from "lucide-react"; // Adjust icons as needed
|
||||
import { ItemDTO, User } from "@/shared/dto/user/usersDto";
|
||||
import { t } from "i18next";
|
||||
import ActivateArchivedUser from "./ActivateArchivedUser";
|
||||
|
||||
type ActionsCellProps = {
|
||||
row: ItemDTO;
|
||||
};
|
||||
|
||||
const ArchivedUserActionsCell: React.FC<ActionsCellProps> = ({ row }) => {
|
||||
const navigate = useNavigate();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const handleEdit = () => {
|
||||
navigate(`/user-management/archive/edit/${row?.userId}`);
|
||||
};
|
||||
|
||||
const handleDelete = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDropdownOpen(false);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const handleActivateModal = () => {
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">Open actions menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onInteractOutside={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('[role="dialog"]')) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}}>
|
||||
<DropdownMenuLabel>{t("userRecord.Actions")}</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={handleEdit}
|
||||
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
|
||||
<Edit className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
|
||||
<span> {t("userRecord.Edit")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleActivateModal}
|
||||
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
|
||||
<UserPlus className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
|
||||
<span> {t("userRecord.Activate")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={handleDelete}
|
||||
className="text-red-600 cursor-pointer hover:!text-red-800 !bg-transparent !transition-colors duration-200">
|
||||
<Trash className="mr-2 h-4 w-4 text-red-600 group-hover:text-white transition-colors duration-200" />
|
||||
<span> {t("userRecord.Delete")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{modalOpen && (
|
||||
<ActivateArchivedUser
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
userId={row.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* {showDeleteDialog && (
|
||||
<DeletePositionTypes
|
||||
id={row.id}
|
||||
onSuccess={() => setShowDeleteDialog(false)}
|
||||
onClose={() => setShowDeleteDialog(false)}
|
||||
/>
|
||||
)} */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArchivedUserActionsCell;
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/shared/common/ui/dropdown-menu"; // Adjust if needed
|
||||
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { MoreVertical, Edit, Trash, UserPlus } from "lucide-react"; // Adjust icons as needed
|
||||
import { ItemDTO, User } from "@/shared/dto/user/usersDto";
|
||||
import { t } from "i18next";
|
||||
import ActivateArchivedUser from "./ActivateArchivedUser";
|
||||
|
||||
type ActionsCellProps = {
|
||||
row: ItemDTO;
|
||||
};
|
||||
|
||||
const ArchivedUserActionsCell: React.FC<ActionsCellProps> = ({ row }) => {
|
||||
const navigate = useNavigate();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const handleEdit = () => {
|
||||
navigate(`/user-management/archive/edit/${row?.userId}`);
|
||||
};
|
||||
|
||||
const handleDelete = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDropdownOpen(false);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const handleActivateModal = () => {
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">Open actions menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onInteractOutside={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('[role="dialog"]')) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}}>
|
||||
<DropdownMenuLabel>{t("userRecord.Actions")}</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={handleEdit}
|
||||
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
|
||||
<Edit className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
|
||||
<span> {t("userRecord.Edit")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleActivateModal}
|
||||
className="cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200">
|
||||
<UserPlus className="mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200" />
|
||||
<span> {t("userRecord.Activate")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={handleDelete}
|
||||
className="text-red-600 cursor-pointer hover:!text-red-800 !bg-transparent !transition-colors duration-200">
|
||||
<Trash className="mr-2 h-4 w-4 text-red-600 group-hover:text-white transition-colors duration-200" />
|
||||
<span> {t("userRecord.Delete")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{modalOpen && (
|
||||
<ActivateArchivedUser
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
userId={row.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* {showDeleteDialog && (
|
||||
<DeletePositionTypes
|
||||
id={row.id}
|
||||
onSuccess={() => setShowDeleteDialog(false)}
|
||||
onClose={() => setShowDeleteDialog(false)}
|
||||
/>
|
||||
)} */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArchivedUserActionsCell;
|
||||
|
||||
@@ -1,268 +1,268 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Card } from "@/shared/common/ui/card";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Label } from "@radix-ui/react-label";
|
||||
import { Plus, Trash2, Pencil } from "lucide-react";
|
||||
import {
|
||||
prefixSuffixService,
|
||||
RemarkPayload,
|
||||
} from "@/user-management/services/api/prefixSuffixService";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRemarkByUnitId } from "@/super-admin/hooks/useRemark";
|
||||
import { t } from "i18next";
|
||||
|
||||
interface Props {
|
||||
unitId: string;
|
||||
}
|
||||
|
||||
interface RemarkDto {
|
||||
id: string;
|
||||
remark: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const CommonRemarks = ({ unitId }: Props) => {
|
||||
const [remark, setRemark] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deleteRemarkId, setDeleteRemarkId] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data: remarksData, isLoading: isRemarkLoading } =
|
||||
useRemarkByUnitId(unitId,{
|
||||
skip: 0,
|
||||
take: 300,
|
||||
});
|
||||
|
||||
// Save/Edit Remark
|
||||
const saveRemarkMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!remark) throw new Error(t("contentManagement.remarkRequired"));
|
||||
|
||||
const payload: RemarkPayload = { unitId, remark, description };
|
||||
|
||||
if (editingId) {
|
||||
return prefixSuffixService.editRemark(editingId, payload);
|
||||
}
|
||||
return prefixSuffixService.createRemark(payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["remark", unitId] });
|
||||
resetForm();
|
||||
setIsDialogOpen(false);
|
||||
},
|
||||
onError: (err: any) =>
|
||||
setError(err.message || t("contentManagement.failed")),
|
||||
});
|
||||
|
||||
// Delete Remark
|
||||
const deleteRemarkMutation = useMutation({
|
||||
mutationFn: (id: string) => prefixSuffixService.deleteRemarks(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["remark", unitId] });
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeleteRemarkId(null);
|
||||
},
|
||||
onError: () => setError(t("contentManagement.failedToDelete")),
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setRemark("");
|
||||
setDescription("");
|
||||
setEditingId(null);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const handleEdit = (item: RemarkDto) => {
|
||||
setEditingId(item.id);
|
||||
setRemark(item.remark);
|
||||
setDescription(item.description || "");
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = () => saveRemarkMutation.mutate();
|
||||
|
||||
return (
|
||||
<Card className="p-4 border shadow-sm space-y-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
{/* Add/Edit Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-[180px] bg-primary hover:bg-primary/90 text-primary-foreground flex items-center">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("contentManagement.addRemark")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-xl w-full dark:bg-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="dark:text-white">
|
||||
{editingId
|
||||
? t("contentManagement.editRemark")
|
||||
: t("contentManagement.addRemark")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="remark" className="mb-1 dark:text-gray-200">
|
||||
{t("header.Remark")}
|
||||
</Label>
|
||||
<Input
|
||||
id="remark"
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.target.value)}
|
||||
placeholder={t("header.Remark")}
|
||||
disabled={isRemarkLoading}
|
||||
className="w-full dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
<Label htmlFor="description" className="mb-1 dark:text-gray-200">
|
||||
{t("header.Description")}
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("header.Description")}
|
||||
disabled={isRemarkLoading}
|
||||
className="w-full mb-2 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setIsDialogOpen(false);
|
||||
}}>
|
||||
{t("common.Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isRemarkLoading} // ✅ access via mutation object
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground">
|
||||
{isRemarkLoading
|
||||
? t("contentManagement.saving")
|
||||
: editingId
|
||||
? t("delegation.update")
|
||||
: t("delegation.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Remarks Table */}
|
||||
<h3 className="font-semibold text-md dark:text-white">
|
||||
{t("contentManagement.commonRemarks")}
|
||||
</h3>
|
||||
{isRemarkLoading ? (
|
||||
<div className="text-center py-4 dark:text-gray-400">{t("contentManagement.loading")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto max-h-[380px] overflow-y-auto border rounded dark:border-gray-600">
|
||||
<table className="min-w-full text-sm text-left">
|
||||
<thead>
|
||||
<tr className="border-b bg-gray-100 dark:bg-gray-700">
|
||||
<th className="px-3 py-2 dark:text-white">#</th>
|
||||
<th className="px-3 py-2 dark:text-white">{t("header.Remark")}</th>
|
||||
<th className="px-3 py-2 dark:text-white">{t("header.Description")}</th>
|
||||
<th className="px-3 py-2 dark:text-white">{t("userRecord.Actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{remarksData?.items.length ? (
|
||||
remarksData.items.map((item: any, idx: any) => (
|
||||
<tr key={item.id} className="border-b hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600">
|
||||
<td className="px-3 py-2 dark:text-gray-300">{idx + 1}</td>
|
||||
<td className="px-3 py-2 dark:text-gray-300">{item.remark}</td>
|
||||
<td className="px-3 py-2 dark:text-gray-300">{item.description}</td>
|
||||
<td className="px-3 py-2 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(item)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDeleteRemarkId(item.id);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center py-4 text-gray-500 dark:text-gray-400">
|
||||
{t("contentManagement.noRec")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (deleteRemarkMutation.isPending) return;
|
||||
setIsDeleteDialogOpen(open);
|
||||
if (!open) setDeleteRemarkId(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("common.delete")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("contentManagement.deleteMsg")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteRemarkMutation.isPending}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={deleteRemarkMutation.isPending || !deleteRemarkId}
|
||||
onClick={() => {
|
||||
if (!deleteRemarkId) return;
|
||||
deleteRemarkMutation.mutate(deleteRemarkId);
|
||||
}}
|
||||
>
|
||||
{deleteRemarkMutation.isPending
|
||||
? t("common.deleting", "Deleting...")
|
||||
: t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Card } from "@/shared/common/ui/card";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Label } from "@radix-ui/react-label";
|
||||
import { Plus, Trash2, Pencil } from "lucide-react";
|
||||
import {
|
||||
prefixSuffixService,
|
||||
RemarkPayload,
|
||||
} from "@/user-management/services/api/prefixSuffixService";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRemarkByUnitId } from "@/super-admin/hooks/useRemark";
|
||||
import { t } from "i18next";
|
||||
|
||||
interface Props {
|
||||
unitId: string;
|
||||
}
|
||||
|
||||
interface RemarkDto {
|
||||
id: string;
|
||||
remark: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const CommonRemarks = ({ unitId }: Props) => {
|
||||
const [remark, setRemark] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deleteRemarkId, setDeleteRemarkId] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data: remarksData, isLoading: isRemarkLoading } =
|
||||
useRemarkByUnitId(unitId,{
|
||||
skip: 0,
|
||||
take: 300,
|
||||
});
|
||||
|
||||
// Save/Edit Remark
|
||||
const saveRemarkMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!remark) throw new Error(t("contentManagement.remarkRequired"));
|
||||
|
||||
const payload: RemarkPayload = { unitId, remark, description };
|
||||
|
||||
if (editingId) {
|
||||
return prefixSuffixService.editRemark(editingId, payload);
|
||||
}
|
||||
return prefixSuffixService.createRemark(payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["remark", unitId] });
|
||||
resetForm();
|
||||
setIsDialogOpen(false);
|
||||
},
|
||||
onError: (err: any) =>
|
||||
setError(err.message || t("contentManagement.failed")),
|
||||
});
|
||||
|
||||
// Delete Remark
|
||||
const deleteRemarkMutation = useMutation({
|
||||
mutationFn: (id: string) => prefixSuffixService.deleteRemarks(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["remark", unitId] });
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeleteRemarkId(null);
|
||||
},
|
||||
onError: () => setError(t("contentManagement.failedToDelete")),
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setRemark("");
|
||||
setDescription("");
|
||||
setEditingId(null);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const handleEdit = (item: RemarkDto) => {
|
||||
setEditingId(item.id);
|
||||
setRemark(item.remark);
|
||||
setDescription(item.description || "");
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = () => saveRemarkMutation.mutate();
|
||||
|
||||
return (
|
||||
<Card className="p-4 border shadow-sm space-y-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
{/* Add/Edit Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-[180px] bg-primary hover:bg-primary/90 text-primary-foreground flex items-center">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("contentManagement.addRemark")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-xl w-full dark:bg-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="dark:text-white">
|
||||
{editingId
|
||||
? t("contentManagement.editRemark")
|
||||
: t("contentManagement.addRemark")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="remark" className="mb-1 dark:text-gray-200">
|
||||
{t("header.Remark")}
|
||||
</Label>
|
||||
<Input
|
||||
id="remark"
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.target.value)}
|
||||
placeholder={t("header.Remark")}
|
||||
disabled={isRemarkLoading}
|
||||
className="w-full dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
<Label htmlFor="description" className="mb-1 dark:text-gray-200">
|
||||
{t("header.Description")}
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t("header.Description")}
|
||||
disabled={isRemarkLoading}
|
||||
className="w-full mb-2 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setIsDialogOpen(false);
|
||||
}}>
|
||||
{t("common.Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isRemarkLoading} // ✅ access via mutation object
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground">
|
||||
{isRemarkLoading
|
||||
? t("contentManagement.saving")
|
||||
: editingId
|
||||
? t("delegation.update")
|
||||
: t("delegation.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Remarks Table */}
|
||||
<h3 className="font-semibold text-md dark:text-white">
|
||||
{t("contentManagement.commonRemarks")}
|
||||
</h3>
|
||||
{isRemarkLoading ? (
|
||||
<div className="text-center py-4 dark:text-gray-400">{t("contentManagement.loading")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto max-h-[380px] overflow-y-auto border rounded dark:border-gray-600">
|
||||
<table className="min-w-full text-sm text-left">
|
||||
<thead>
|
||||
<tr className="border-b bg-gray-100 dark:bg-gray-700">
|
||||
<th className="px-3 py-2 dark:text-white">#</th>
|
||||
<th className="px-3 py-2 dark:text-white">{t("header.Remark")}</th>
|
||||
<th className="px-3 py-2 dark:text-white">{t("header.Description")}</th>
|
||||
<th className="px-3 py-2 dark:text-white">{t("userRecord.Actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{remarksData?.items.length ? (
|
||||
remarksData.items.map((item: any, idx: any) => (
|
||||
<tr key={item.id} className="border-b hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600">
|
||||
<td className="px-3 py-2 dark:text-gray-300">{idx + 1}</td>
|
||||
<td className="px-3 py-2 dark:text-gray-300">{item.remark}</td>
|
||||
<td className="px-3 py-2 dark:text-gray-300">{item.description}</td>
|
||||
<td className="px-3 py-2 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(item)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDeleteRemarkId(item.id);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center py-4 text-gray-500 dark:text-gray-400">
|
||||
{t("contentManagement.noRec")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={isDeleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (deleteRemarkMutation.isPending) return;
|
||||
setIsDeleteDialogOpen(open);
|
||||
if (!open) setDeleteRemarkId(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("common.delete")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("contentManagement.deleteMsg")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteRemarkMutation.isPending}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={deleteRemarkMutation.isPending || !deleteRemarkId}
|
||||
onClick={() => {
|
||||
if (!deleteRemarkId) return;
|
||||
deleteRemarkMutation.mutate(deleteRemarkId);
|
||||
}}
|
||||
>
|
||||
{deleteRemarkMutation.isPending
|
||||
? t("common.deleting", "Deleting...")
|
||||
: t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,468 +1,468 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import {
|
||||
HeaderFooterResponseDto,
|
||||
headerFooterService,
|
||||
CreateHeaderFooterPayload,
|
||||
HeaderFooterChangeStatusPayload,
|
||||
} from "@/user-management/services/api/headerFooterService";
|
||||
import { Card } from "@/shared/common/ui/card";
|
||||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import i18n from "@/i18n";
|
||||
import { t } from "i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { presignedAxios } from "@/shared/services/presignedAxios";
|
||||
import useSettings from "@/record-management/components/hooks/useSettings";
|
||||
import { OrganizationsPositions } from "@/record-management/services/api/departmentService";
|
||||
import { MultiSelect } from "@/shared/common/ui/multi-select";
|
||||
import { useUnitConfiguration } from "@/shared/hooks/useUnitConfiguration";
|
||||
import { KeyValue } from "@/record-management/types/recordSelectTypes";
|
||||
import { FormSelectField } from "@/shared/common/form/fields/FormFields";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
|
||||
interface Props {
|
||||
unitId: string;
|
||||
}
|
||||
|
||||
const HeaderAndFooter = ({ unitId }: Props) => {
|
||||
const [headers, setHeaders] = useState<HeaderFooterResponseDto[]>([]);
|
||||
const [footers, setFooters] = useState<HeaderFooterResponseDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newFile, setNewFile] = useState<File | null>(null);
|
||||
const [newType, setNewType] = useState<"header" | "footer">("header");
|
||||
const [newPreview, setNewPreview] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const lang = i18n.language;
|
||||
const [selectedPositions, setSelectedPositions] = useState<string[]>([]);
|
||||
|
||||
const { data: unitConfigData } = useUnitConfiguration(unitId ?? "", {
|
||||
enabled: !!unitId,
|
||||
});
|
||||
const unitConfig = unitConfigData?.data?.items?.[0];
|
||||
const canCreateDirectRecord = unitConfig?.canCreateDirectRecord ?? false;
|
||||
const localizedName = useLocalizedName();
|
||||
const [selectedRecordType, setSelectedRecordType] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const isDirectRecordType = (item: {
|
||||
name?: { en?: string; am?: string };
|
||||
key?: string;
|
||||
}) => {
|
||||
const key = String(item.key || "").toLowerCase();
|
||||
const rawName = [item.name?.en, item.name?.am]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return key.includes("direct") || rawName.includes("direct");
|
||||
};
|
||||
|
||||
const { recordTypes } = useSettings();
|
||||
const { allDepratments } = useSettings();
|
||||
const localizedFormName = useCallback(
|
||||
(name?: { am: string; en: string }) => {
|
||||
if (!name) return "";
|
||||
return lang === "am" ? name.am || name.en : name.en || name.am;
|
||||
},
|
||||
[lang],
|
||||
);
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [headerListRes, footerListRes] = await Promise.all([
|
||||
headerFooterService.getHeadersByUnitId(unitId),
|
||||
headerFooterService.getFootersByUnitId(unitId),
|
||||
]);
|
||||
|
||||
const activeUploadedHeaders = headerListRes.data.items.filter(
|
||||
(item) => item.isCurrent && item.uploadedSuccessfully,
|
||||
);
|
||||
const activeUploadedFooters = footerListRes.data.items.filter(
|
||||
(item) => item.isCurrent && item.uploadedSuccessfully,
|
||||
);
|
||||
|
||||
const withPresigned = async (
|
||||
items: HeaderFooterResponseDto[],
|
||||
type: "header" | "footer",
|
||||
) =>
|
||||
Promise.all(
|
||||
items.map(async (item) => {
|
||||
try {
|
||||
const detail =
|
||||
type === "header"
|
||||
? await headerFooterService.getHeaderById(item.id)
|
||||
: await headerFooterService.getFooterById(item.id);
|
||||
return { ...item, presigned: detail.data.presigned };
|
||||
} catch {
|
||||
return item;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const [activeHeaders, activeFooters] = await Promise.all([
|
||||
withPresigned(activeUploadedHeaders, "header"),
|
||||
withPresigned(activeUploadedFooters, "footer"),
|
||||
]);
|
||||
|
||||
setHeaders(activeHeaders);
|
||||
setFooters(activeFooters);
|
||||
} catch {
|
||||
toast.error(t("contentManagement.failedToLoadHeaderFooter"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [unitId]);
|
||||
|
||||
const handleStatusChange = async (
|
||||
id: string,
|
||||
type: "header" | "footer",
|
||||
isCurrent: boolean,
|
||||
) => {
|
||||
try {
|
||||
const payload: HeaderFooterChangeStatusPayload = { isCurrent };
|
||||
|
||||
if (type === "header") {
|
||||
await headerFooterService.changeHeaderStatus(id, payload);
|
||||
} else {
|
||||
await headerFooterService.changeFooterStatus(id, payload);
|
||||
}
|
||||
|
||||
// Invalidate the query cache so record forms immediately reflect the changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["headers-footers", unitId],
|
||||
});
|
||||
|
||||
toast.success(t("contentManagement.statusChanged"));
|
||||
fetchData();
|
||||
} catch {
|
||||
toast.error(t("contentManagement.statusChangeFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!newName || !newFile) {
|
||||
setError(t("contentManagement.provideMsg"));
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const created = await (newType === "header"
|
||||
? headerFooterService.uploadAndCreateHeader(
|
||||
newFile,
|
||||
newName,
|
||||
unitId,
|
||||
selectedPositions,
|
||||
selectedRecordType,
|
||||
)
|
||||
: headerFooterService.uploadAndCreateFooter(
|
||||
newFile,
|
||||
newName,
|
||||
unitId,
|
||||
selectedPositions,
|
||||
selectedRecordType,
|
||||
));
|
||||
|
||||
// Make the newly uploaded resource active immediately.
|
||||
if (created?.id) {
|
||||
if (newType === "header") {
|
||||
await headerFooterService.changeHeaderStatus(created.id, {
|
||||
isCurrent: true,
|
||||
});
|
||||
} else {
|
||||
await headerFooterService.changeFooterStatus(created.id, {
|
||||
isCurrent: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`${newType} ${t("contentManagement.uploadSuccess")}`);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
setNewFile(null);
|
||||
setNewName("");
|
||||
setNewPreview(null);
|
||||
setError(null);
|
||||
setSelectedPositions([]);
|
||||
setSelectedRecordType(null);
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(t("contentManagement.uploadFailed"));
|
||||
toast.error(t("contentManagement.uploadFailed"));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const recordTypeOptions: KeyValue[] =
|
||||
recordTypes
|
||||
?.filter((item: { name?: { en?: string; am?: string }; key?: string }) =>
|
||||
canCreateDirectRecord ? true : !isDirectRecordType(item),
|
||||
)
|
||||
.map((item: { name: { en: string; am: string }; key: string }) => ({
|
||||
label:
|
||||
item.key == "direct"
|
||||
? localizedName({ en: "Direct Letter", am: "ቀጥታ ደብዳቤ" })
|
||||
: localizedName(item.name),
|
||||
value: item.key,
|
||||
})) || [];
|
||||
|
||||
const renderList = (
|
||||
title: string,
|
||||
items: HeaderFooterResponseDto[],
|
||||
type: "header" | "footer",
|
||||
) => (
|
||||
<Card className="p-6 border shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="font-semibold text-xl dark:text-white">{title}</h2>
|
||||
<span className="text-sm text-muted-foreground dark:text-gray-400">
|
||||
{items.length} {items.length === 1 ? "item" : "items"}
|
||||
</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<Skeleton className="h-40" />
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
|
||||
<span className="text-2xl text-gray-400 dark:text-gray-500">
|
||||
{type === "header" ? "📄" : "📋"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground dark:text-gray-400 mb-2">
|
||||
No {title.toLowerCase()} found
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground dark:text-gray-500">
|
||||
Upload your first {type} using the form below
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`border rounded-lg p-4 ${
|
||||
item.isCurrent
|
||||
? "bg-primary-50 dark:bg-primary-900/30 border-primary-200 dark:border-primary-800 shadow-sm"
|
||||
: "bg-white dark:bg-gray-700"
|
||||
}`}>
|
||||
{/* Header with name and status */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-lg truncate dark:text-white">
|
||||
{lang === "en" ? item.name.en : item.name.am}
|
||||
</h3>
|
||||
{item.isCurrent && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 dark:bg-primary-900/50 text-primary-800 dark:text-primary-300 flex-shrink-0">
|
||||
{t("contentManagement.currentlyActive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground dark:text-gray-400 truncate">
|
||||
{item.fileInfo.fileName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview and Actions */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Preview Image */}
|
||||
{item.presigned && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-24 h-16 border rounded-md overflow-hidden bg-gray-50 dark:bg-gray-600">
|
||||
<img
|
||||
src={item.presigned}
|
||||
alt={`${type} Preview`}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleStatusChange(item.id, type, false)}
|
||||
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 whitespace-nowrap">
|
||||
{t("contentManagement.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid lg:grid-cols-2 gap-8">
|
||||
{renderList("Headers", headers, "header")}
|
||||
{renderList("Footers", footers, "footer")}
|
||||
</div>
|
||||
|
||||
<Card className="p-4 border shadow-sm space-y-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 className="font-semibold text-lg flex items-center gap-2 dark:text-white">
|
||||
<Plus className="h-5 w-5" /> {t("contentManagement.addHeaderFooter")}
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label className="dark:text-gray-200">
|
||||
{t("contentManagement.name")}
|
||||
</Label>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => {
|
||||
setNewName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("contentManagement.name")}
|
||||
disabled={uploading}
|
||||
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="dark:text-gray-200">
|
||||
{t("contentManagement.type")}
|
||||
</Label>
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) =>
|
||||
setNewType(e.target.value as "header" | "footer")
|
||||
}
|
||||
className="w-full rounded border px-3 py-2 text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
disabled={uploading}>
|
||||
<option value="header">{t("contentManagement.header")}</option>
|
||||
<option value="footer">{t("contentManagement.footer")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="dark:text-gray-200">
|
||||
{t("contentManagement.uploadFile")}
|
||||
</Label>
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0] || null;
|
||||
if (file) {
|
||||
const maxSizeMB = 5; // for example, 2 MB
|
||||
if (file.size > maxSizeMB * 1024 * 1024) {
|
||||
setError(`File size must be less than ${maxSizeMB} MB`);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
setNewFile(null);
|
||||
setNewPreview(null);
|
||||
return;
|
||||
}
|
||||
setNewFile(file);
|
||||
setNewPreview(URL.createObjectURL(file));
|
||||
} else {
|
||||
setNewFile(null);
|
||||
setNewPreview(null);
|
||||
}
|
||||
setError(null);
|
||||
}}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="dark:text-gray-200">
|
||||
{t("contentManagement.positions")}
|
||||
</Label>
|
||||
<MultiSelect
|
||||
options={
|
||||
(allDepratments as OrganizationsPositions[] | undefined)?.map(
|
||||
(dept) => ({
|
||||
label: localizedFormName(dept.name),
|
||||
value: dept.id,
|
||||
}),
|
||||
) ?? []
|
||||
}
|
||||
value={selectedPositions}
|
||||
onValueChange={setSelectedPositions}
|
||||
placeholder={t("contentManagement.selectPositions")}
|
||||
maxCount={5}
|
||||
className="w-full max-w-full sm:max-w-md overflow-x-auto"
|
||||
animation={0}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="dark:text-gray-200">
|
||||
{t("addRecord.Record Type")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedRecordType ?? ""}
|
||||
onValueChange={(val) => setSelectedRecordType(val || null)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("addRecord.Select Record Type")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{recordTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newPreview && (
|
||||
<div className="mt-2">
|
||||
<Label className="dark:text-gray-200">
|
||||
{t("contentManagement.preview")}
|
||||
</Label>
|
||||
<div className="border rounded p-2 inline-block mt-1 dark:border-gray-600">
|
||||
<img
|
||||
src={newPreview}
|
||||
alt="Preview"
|
||||
className="h-24 object-contain rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground w-full">
|
||||
{uploading
|
||||
? t("PDF.uploading")
|
||||
: `${t("signatureUpload.upload")} ${newType}`}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderAndFooter;
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import {
|
||||
HeaderFooterResponseDto,
|
||||
headerFooterService,
|
||||
CreateHeaderFooterPayload,
|
||||
HeaderFooterChangeStatusPayload,
|
||||
} from "@/user-management/services/api/headerFooterService";
|
||||
import { Card } from "@/shared/common/ui/card";
|
||||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import i18n from "@/i18n";
|
||||
import { t } from "i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { presignedAxios } from "@/shared/services/presignedAxios";
|
||||
import useSettings from "@/record-management/components/hooks/useSettings";
|
||||
import { OrganizationsPositions } from "@/record-management/services/api/departmentService";
|
||||
import { MultiSelect } from "@/shared/common/ui/multi-select";
|
||||
import { useUnitConfiguration } from "@/shared/hooks/useUnitConfiguration";
|
||||
import { KeyValue } from "@/record-management/types/recordSelectTypes";
|
||||
import { FormSelectField } from "@/shared/common/form/fields/FormFields";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
|
||||
interface Props {
|
||||
unitId: string;
|
||||
}
|
||||
|
||||
const HeaderAndFooter = ({ unitId }: Props) => {
|
||||
const [headers, setHeaders] = useState<HeaderFooterResponseDto[]>([]);
|
||||
const [footers, setFooters] = useState<HeaderFooterResponseDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newFile, setNewFile] = useState<File | null>(null);
|
||||
const [newType, setNewType] = useState<"header" | "footer">("header");
|
||||
const [newPreview, setNewPreview] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const lang = i18n.language;
|
||||
const [selectedPositions, setSelectedPositions] = useState<string[]>([]);
|
||||
|
||||
const { data: unitConfigData } = useUnitConfiguration(unitId ?? "", {
|
||||
enabled: !!unitId,
|
||||
});
|
||||
const unitConfig = unitConfigData?.data?.items?.[0];
|
||||
const canCreateDirectRecord = unitConfig?.canCreateDirectRecord ?? false;
|
||||
const localizedName = useLocalizedName();
|
||||
const [selectedRecordType, setSelectedRecordType] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const isDirectRecordType = (item: {
|
||||
name?: { en?: string; am?: string };
|
||||
key?: string;
|
||||
}) => {
|
||||
const key = String(item.key || "").toLowerCase();
|
||||
const rawName = [item.name?.en, item.name?.am]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return key.includes("direct") || rawName.includes("direct");
|
||||
};
|
||||
|
||||
const { recordTypes } = useSettings();
|
||||
const { allDepratments } = useSettings();
|
||||
const localizedFormName = useCallback(
|
||||
(name?: { am: string; en: string }) => {
|
||||
if (!name) return "";
|
||||
return lang === "am" ? name.am || name.en : name.en || name.am;
|
||||
},
|
||||
[lang],
|
||||
);
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [headerListRes, footerListRes] = await Promise.all([
|
||||
headerFooterService.getHeadersByUnitId(unitId),
|
||||
headerFooterService.getFootersByUnitId(unitId),
|
||||
]);
|
||||
|
||||
const activeUploadedHeaders = headerListRes.data.items.filter(
|
||||
(item) => item.isCurrent && item.uploadedSuccessfully,
|
||||
);
|
||||
const activeUploadedFooters = footerListRes.data.items.filter(
|
||||
(item) => item.isCurrent && item.uploadedSuccessfully,
|
||||
);
|
||||
|
||||
const withPresigned = async (
|
||||
items: HeaderFooterResponseDto[],
|
||||
type: "header" | "footer",
|
||||
) =>
|
||||
Promise.all(
|
||||
items.map(async (item) => {
|
||||
try {
|
||||
const detail =
|
||||
type === "header"
|
||||
? await headerFooterService.getHeaderById(item.id)
|
||||
: await headerFooterService.getFooterById(item.id);
|
||||
return { ...item, presigned: detail.data.presigned };
|
||||
} catch {
|
||||
return item;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const [activeHeaders, activeFooters] = await Promise.all([
|
||||
withPresigned(activeUploadedHeaders, "header"),
|
||||
withPresigned(activeUploadedFooters, "footer"),
|
||||
]);
|
||||
|
||||
setHeaders(activeHeaders);
|
||||
setFooters(activeFooters);
|
||||
} catch {
|
||||
toast.error(t("contentManagement.failedToLoadHeaderFooter"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [unitId]);
|
||||
|
||||
const handleStatusChange = async (
|
||||
id: string,
|
||||
type: "header" | "footer",
|
||||
isCurrent: boolean,
|
||||
) => {
|
||||
try {
|
||||
const payload: HeaderFooterChangeStatusPayload = { isCurrent };
|
||||
|
||||
if (type === "header") {
|
||||
await headerFooterService.changeHeaderStatus(id, payload);
|
||||
} else {
|
||||
await headerFooterService.changeFooterStatus(id, payload);
|
||||
}
|
||||
|
||||
// Invalidate the query cache so record forms immediately reflect the changes
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["headers-footers", unitId],
|
||||
});
|
||||
|
||||
toast.success(t("contentManagement.statusChanged"));
|
||||
fetchData();
|
||||
} catch {
|
||||
toast.error(t("contentManagement.statusChangeFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!newName || !newFile) {
|
||||
setError(t("contentManagement.provideMsg"));
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const created = await (newType === "header"
|
||||
? headerFooterService.uploadAndCreateHeader(
|
||||
newFile,
|
||||
newName,
|
||||
unitId,
|
||||
selectedPositions,
|
||||
selectedRecordType,
|
||||
)
|
||||
: headerFooterService.uploadAndCreateFooter(
|
||||
newFile,
|
||||
newName,
|
||||
unitId,
|
||||
selectedPositions,
|
||||
selectedRecordType,
|
||||
));
|
||||
|
||||
// Make the newly uploaded resource active immediately.
|
||||
if (created?.id) {
|
||||
if (newType === "header") {
|
||||
await headerFooterService.changeHeaderStatus(created.id, {
|
||||
isCurrent: true,
|
||||
});
|
||||
} else {
|
||||
await headerFooterService.changeFooterStatus(created.id, {
|
||||
isCurrent: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`${newType} ${t("contentManagement.uploadSuccess")}`);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
setNewFile(null);
|
||||
setNewName("");
|
||||
setNewPreview(null);
|
||||
setError(null);
|
||||
setSelectedPositions([]);
|
||||
setSelectedRecordType(null);
|
||||
fetchData();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(t("contentManagement.uploadFailed"));
|
||||
toast.error(t("contentManagement.uploadFailed"));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const recordTypeOptions: KeyValue[] =
|
||||
recordTypes
|
||||
?.filter((item: { name?: { en?: string; am?: string }; key?: string }) =>
|
||||
canCreateDirectRecord ? true : !isDirectRecordType(item),
|
||||
)
|
||||
.map((item: { name: { en: string; am: string }; key: string }) => ({
|
||||
label:
|
||||
item.key == "direct"
|
||||
? localizedName({ en: "Direct Letter", am: "ቀጥታ ደብዳቤ" })
|
||||
: localizedName(item.name),
|
||||
value: item.key,
|
||||
})) || [];
|
||||
|
||||
const renderList = (
|
||||
title: string,
|
||||
items: HeaderFooterResponseDto[],
|
||||
type: "header" | "footer",
|
||||
) => (
|
||||
<Card className="p-6 border shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="font-semibold text-xl dark:text-white">{title}</h2>
|
||||
<span className="text-sm text-muted-foreground dark:text-gray-400">
|
||||
{items.length} {items.length === 1 ? "item" : "items"}
|
||||
</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<Skeleton className="h-40" />
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
|
||||
<span className="text-2xl text-gray-400 dark:text-gray-500">
|
||||
{type === "header" ? "📄" : "📋"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground dark:text-gray-400 mb-2">
|
||||
No {title.toLowerCase()} found
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground dark:text-gray-500">
|
||||
Upload your first {type} using the form below
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`border rounded-lg p-4 ${
|
||||
item.isCurrent
|
||||
? "bg-primary-50 dark:bg-primary-900/30 border-primary-200 dark:border-primary-800 shadow-sm"
|
||||
: "bg-white dark:bg-gray-700"
|
||||
}`}>
|
||||
{/* Header with name and status */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-lg truncate dark:text-white">
|
||||
{lang === "en" ? item.name.en : item.name.am}
|
||||
</h3>
|
||||
{item.isCurrent && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 dark:bg-primary-900/50 text-primary-800 dark:text-primary-300 flex-shrink-0">
|
||||
{t("contentManagement.currentlyActive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground dark:text-gray-400 truncate">
|
||||
{item.fileInfo.fileName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview and Actions */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Preview Image */}
|
||||
{item.presigned && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-24 h-16 border rounded-md overflow-hidden bg-gray-50 dark:bg-gray-600">
|
||||
<img
|
||||
src={item.presigned}
|
||||
alt={`${type} Preview`}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleStatusChange(item.id, type, false)}
|
||||
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 whitespace-nowrap">
|
||||
{t("contentManagement.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid lg:grid-cols-2 gap-8">
|
||||
{renderList("Headers", headers, "header")}
|
||||
{renderList("Footers", footers, "footer")}
|
||||
</div>
|
||||
|
||||
<Card className="p-4 border shadow-sm space-y-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 className="font-semibold text-lg flex items-center gap-2 dark:text-white">
|
||||
<Plus className="h-5 w-5" /> {t("contentManagement.addHeaderFooter")}
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label className="dark:text-gray-200">
|
||||
{t("contentManagement.name")}
|
||||
</Label>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => {
|
||||
setNewName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("contentManagement.name")}
|
||||
disabled={uploading}
|
||||
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="dark:text-gray-200">
|
||||
{t("contentManagement.type")}
|
||||
</Label>
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) =>
|
||||
setNewType(e.target.value as "header" | "footer")
|
||||
}
|
||||
className="w-full rounded border px-3 py-2 text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
disabled={uploading}>
|
||||
<option value="header">{t("contentManagement.header")}</option>
|
||||
<option value="footer">{t("contentManagement.footer")}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="dark:text-gray-200">
|
||||
{t("contentManagement.uploadFile")}
|
||||
</Label>
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0] || null;
|
||||
if (file) {
|
||||
const maxSizeMB = 5; // for example, 2 MB
|
||||
if (file.size > maxSizeMB * 1024 * 1024) {
|
||||
setError(`File size must be less than ${maxSizeMB} MB`);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
setNewFile(null);
|
||||
setNewPreview(null);
|
||||
return;
|
||||
}
|
||||
setNewFile(file);
|
||||
setNewPreview(URL.createObjectURL(file));
|
||||
} else {
|
||||
setNewFile(null);
|
||||
setNewPreview(null);
|
||||
}
|
||||
setError(null);
|
||||
}}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="dark:text-gray-200">
|
||||
{t("contentManagement.positions")}
|
||||
</Label>
|
||||
<MultiSelect
|
||||
options={
|
||||
(allDepratments as OrganizationsPositions[] | undefined)?.map(
|
||||
(dept) => ({
|
||||
label: localizedFormName(dept.name),
|
||||
value: dept.id,
|
||||
}),
|
||||
) ?? []
|
||||
}
|
||||
value={selectedPositions}
|
||||
onValueChange={setSelectedPositions}
|
||||
placeholder={t("contentManagement.selectPositions")}
|
||||
maxCount={5}
|
||||
className="w-full max-w-full sm:max-w-md overflow-x-auto"
|
||||
animation={0}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="dark:text-gray-200">
|
||||
{t("addRecord.Record Type")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedRecordType ?? ""}
|
||||
onValueChange={(val) => setSelectedRecordType(val || null)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("addRecord.Select Record Type")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{recordTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newPreview && (
|
||||
<div className="mt-2">
|
||||
<Label className="dark:text-gray-200">
|
||||
{t("contentManagement.preview")}
|
||||
</Label>
|
||||
<div className="border rounded p-2 inline-block mt-1 dark:border-gray-600">
|
||||
<img
|
||||
src={newPreview}
|
||||
alt="Preview"
|
||||
className="h-24 object-contain rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground w-full">
|
||||
{uploading
|
||||
? t("PDF.uploading")
|
||||
: `${t("signatureUpload.upload")} ${newType}`}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderAndFooter;
|
||||
|
||||
@@ -1,377 +1,377 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
LetterTemplate,
|
||||
LetterTemplatePayload,
|
||||
} from "@/user-management/services/api/letterTemplateService";
|
||||
import { TemplateEditor } from "@/super-admin/components/templates/components/TemplateEditor";
|
||||
import { useHeaderFooterPresigned } from "@/record-management/components/hooks/useHeaderFooterPresigned";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/shared/common/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { Card, CardContent } from "@/shared/common/ui/card";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
|
||||
interface Props {
|
||||
unitId: string;
|
||||
template?: LetterTemplate | null;
|
||||
isSubmitting: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmitCreate: (data: LetterTemplatePayload) => void;
|
||||
}
|
||||
|
||||
export const LetterTemplateForm = ({
|
||||
unitId,
|
||||
template,
|
||||
isSubmitting,
|
||||
onCancel,
|
||||
onSubmitCreate,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
const [selectedHeader, setSelectedHeader] = useState<string>("");
|
||||
const [selectedFooter, setSelectedFooter] = useState<string>("");
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<LetterTemplatePayload>({
|
||||
defaultValues: {
|
||||
name: {
|
||||
en: template?.name?.en ?? "",
|
||||
am: template?.name?.am ?? "",
|
||||
},
|
||||
key: template?.key ?? "",
|
||||
subject: template?.subject ?? "",
|
||||
body: template?.body ?? "",
|
||||
sincerelyText: template?.sincerelyText ?? "",
|
||||
headerId: template?.headerId ?? "",
|
||||
footerId: template?.footerId ?? "",
|
||||
unitId,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: headerFooterData } = useHeaderFooterPresigned(unitId);
|
||||
const headers = useMemo(
|
||||
() => headerFooterData?.headers || [],
|
||||
[headerFooterData?.headers],
|
||||
);
|
||||
const footers = useMemo(
|
||||
() => headerFooterData?.footers || [],
|
||||
[headerFooterData?.footers],
|
||||
);
|
||||
|
||||
const selectedHeaderId = watch("headerId");
|
||||
const selectedFooterId = watch("footerId");
|
||||
|
||||
// Update header preview when selection changes
|
||||
useEffect(() => {
|
||||
if (headers.length && selectedHeaderId) {
|
||||
const header = headers.find((h) => h.id === selectedHeaderId);
|
||||
if (header) {
|
||||
setSelectedHeader(
|
||||
`<div class="letter-header"><img src="${header.presigned}" alt="Header" style="width: 100%; max-height: 150px;" /></div>`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setSelectedHeader("");
|
||||
}
|
||||
}, [headers, selectedHeaderId]);
|
||||
|
||||
// Update footer preview when selection changes
|
||||
useEffect(() => {
|
||||
if (footers.length && selectedFooterId) {
|
||||
const footer = footers.find((f) => f.id === selectedFooterId);
|
||||
if (footer) {
|
||||
setSelectedFooter(
|
||||
`<div class="letter-footer"><img src="${footer.presigned}" alt="Footer" style="width: 100%; max-height: 150px;" /></div>`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setSelectedFooter("");
|
||||
}
|
||||
}, [footers, selectedFooterId]);
|
||||
|
||||
const onSubmit = (values: LetterTemplatePayload) => {
|
||||
onSubmitCreate(values);
|
||||
if (!template) {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
// Header options for select
|
||||
const headerOptions = headers.map((header) => ({
|
||||
label: localizedName(header.name),
|
||||
value: header.id,
|
||||
}));
|
||||
|
||||
// Footer options for select
|
||||
const footerOptions = footers.map((footer) => ({
|
||||
label: localizedName(footer.name),
|
||||
value: footer.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg p-8 shadow-sm space-y-8"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("template.key", "Template Key")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("template.keyPlaceholder", "Template Key")}
|
||||
{...register("key", {
|
||||
required: t("template.keyRequired", "Template key is required"),
|
||||
})}
|
||||
/>
|
||||
{errors.key && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.key.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("template.subject", "Subject")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("template.subjectPlaceholder", "Subject")}
|
||||
{...register("subject", {
|
||||
required: t("template.subjectRequired", "Subject is required"),
|
||||
})}
|
||||
/>
|
||||
{errors.subject && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{errors.subject.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("template.englishName", "English Name")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("template.englishNamePlaceholder", "English Name")}
|
||||
{...register("name.en", {
|
||||
required: t(
|
||||
"template.englishNameRequired",
|
||||
"English name is required",
|
||||
),
|
||||
})}
|
||||
/>
|
||||
{errors.name?.en && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{errors.name.en.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("template.amharicName", "Amharic Name")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("template.amharicNamePlaceholder", "Amharic Name")}
|
||||
{...register("name.am", {
|
||||
required: t(
|
||||
"template.amharicNameRequired",
|
||||
"Amharic name is required",
|
||||
),
|
||||
})}
|
||||
/>
|
||||
{errors.name?.am && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{errors.name.am.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header Selection */}
|
||||
{headerOptions.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("template.header", "Header")}
|
||||
</label>
|
||||
<select
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
value={selectedHeaderId || ""}
|
||||
onChange={(e) => setValue("headerId", e.target.value)}
|
||||
>
|
||||
<option value="">{t("template.noHeader", "No Header")}</option>
|
||||
{headerOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer Selection */}
|
||||
{footerOptions.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("template.footer", "Footer")}
|
||||
</label>
|
||||
<select
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
value={selectedFooterId || ""}
|
||||
onChange={(e) => setValue("footerId", e.target.value)}
|
||||
>
|
||||
<option value="">{t("template.noFooter", "No Footer")}</option>
|
||||
{footerOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header Preview */}
|
||||
{selectedHeader && (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">
|
||||
{t("template.headerPreview", "Header Preview")}
|
||||
</p>
|
||||
<div
|
||||
className="bg-muted rounded-lg p-4 border-2 border-dashed border-muted-foreground/20"
|
||||
dangerouslySetInnerHTML={{ __html: selectedHeader }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Body Section */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("template.body", "Body")}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</label>
|
||||
<Controller
|
||||
name="body"
|
||||
control={control}
|
||||
rules={{ required: t("template.bodyRequired", "Body is required") }}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<TemplateEditor
|
||||
value={field.value}
|
||||
onEditorChange={field.onChange}
|
||||
placeholders={[
|
||||
{
|
||||
key: "delegatorName",
|
||||
label: t("placeholder.delegatorName", "Delegator Name"),
|
||||
},
|
||||
{
|
||||
key: "delegatorDepartment",
|
||||
label: t(
|
||||
"placeholder.delegatorDepartment",
|
||||
"Delegator Department",
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "delegateeName",
|
||||
label: t("placeholder.delegateeName", "Delegatee Name"),
|
||||
},
|
||||
{
|
||||
key: "delegateeDepartment",
|
||||
label: t(
|
||||
"placeholder.delegateeDepartment",
|
||||
"Delegatee Department",
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "startDate",
|
||||
label: t("placeholder.startDate", "Start Date"),
|
||||
},
|
||||
{
|
||||
key: "endDate",
|
||||
label: t("placeholder.endDate", "End Date"),
|
||||
},
|
||||
{
|
||||
key: "startDateTime",
|
||||
label: t("placeholder.startDateTime", "Start Date & Time"),
|
||||
},
|
||||
{
|
||||
key: "endDateTime",
|
||||
label: t("placeholder.endDateTime", "End Date & Time"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{fieldState.error?.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer Preview */}
|
||||
{selectedFooter && (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">
|
||||
{t("template.footerPreview", "Footer Preview")}
|
||||
</p>
|
||||
<div
|
||||
className="bg-muted rounded-lg p-4 border-2 border-dashed border-muted-foreground/20"
|
||||
dangerouslySetInnerHTML={{ __html: selectedFooter }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Sincerely Text */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("template.sincerelyText", "Sincerely Text")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("template.sincerelyTextPlaceholder", "Sincerely Text")}
|
||||
{...register("sincerelyText")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-4 justify-end">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? t("common.saving", "Saving...")
|
||||
: template
|
||||
? t("common.update", "Update")
|
||||
: t("common.save", "Save")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
LetterTemplate,
|
||||
LetterTemplatePayload,
|
||||
} from "@/user-management/services/api/letterTemplateService";
|
||||
import { TemplateEditor } from "@/super-admin/components/templates/components/TemplateEditor";
|
||||
import { useHeaderFooterPresigned } from "@/record-management/components/hooks/useHeaderFooterPresigned";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/shared/common/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { Card, CardContent } from "@/shared/common/ui/card";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
|
||||
interface Props {
|
||||
unitId: string;
|
||||
template?: LetterTemplate | null;
|
||||
isSubmitting: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmitCreate: (data: LetterTemplatePayload) => void;
|
||||
}
|
||||
|
||||
export const LetterTemplateForm = ({
|
||||
unitId,
|
||||
template,
|
||||
isSubmitting,
|
||||
onCancel,
|
||||
onSubmitCreate,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const localizedName = useLocalizedName();
|
||||
const [selectedHeader, setSelectedHeader] = useState<string>("");
|
||||
const [selectedFooter, setSelectedFooter] = useState<string>("");
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<LetterTemplatePayload>({
|
||||
defaultValues: {
|
||||
name: {
|
||||
en: template?.name?.en ?? "",
|
||||
am: template?.name?.am ?? "",
|
||||
},
|
||||
key: template?.key ?? "",
|
||||
subject: template?.subject ?? "",
|
||||
body: template?.body ?? "",
|
||||
sincerelyText: template?.sincerelyText ?? "",
|
||||
headerId: template?.headerId ?? "",
|
||||
footerId: template?.footerId ?? "",
|
||||
unitId,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: headerFooterData } = useHeaderFooterPresigned(unitId);
|
||||
const headers = useMemo(
|
||||
() => headerFooterData?.headers || [],
|
||||
[headerFooterData?.headers],
|
||||
);
|
||||
const footers = useMemo(
|
||||
() => headerFooterData?.footers || [],
|
||||
[headerFooterData?.footers],
|
||||
);
|
||||
|
||||
const selectedHeaderId = watch("headerId");
|
||||
const selectedFooterId = watch("footerId");
|
||||
|
||||
// Update header preview when selection changes
|
||||
useEffect(() => {
|
||||
if (headers.length && selectedHeaderId) {
|
||||
const header = headers.find((h) => h.id === selectedHeaderId);
|
||||
if (header) {
|
||||
setSelectedHeader(
|
||||
`<div class="letter-header"><img src="${header.presigned}" alt="Header" style="width: 100%; max-height: 150px;" /></div>`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setSelectedHeader("");
|
||||
}
|
||||
}, [headers, selectedHeaderId]);
|
||||
|
||||
// Update footer preview when selection changes
|
||||
useEffect(() => {
|
||||
if (footers.length && selectedFooterId) {
|
||||
const footer = footers.find((f) => f.id === selectedFooterId);
|
||||
if (footer) {
|
||||
setSelectedFooter(
|
||||
`<div class="letter-footer"><img src="${footer.presigned}" alt="Footer" style="width: 100%; max-height: 150px;" /></div>`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setSelectedFooter("");
|
||||
}
|
||||
}, [footers, selectedFooterId]);
|
||||
|
||||
const onSubmit = (values: LetterTemplatePayload) => {
|
||||
onSubmitCreate(values);
|
||||
if (!template) {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
// Header options for select
|
||||
const headerOptions = headers.map((header) => ({
|
||||
label: localizedName(header.name),
|
||||
value: header.id,
|
||||
}));
|
||||
|
||||
// Footer options for select
|
||||
const footerOptions = footers.map((footer) => ({
|
||||
label: localizedName(footer.name),
|
||||
value: footer.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg p-8 shadow-sm space-y-8"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("template.key", "Template Key")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("template.keyPlaceholder", "Template Key")}
|
||||
{...register("key", {
|
||||
required: t("template.keyRequired", "Template key is required"),
|
||||
})}
|
||||
/>
|
||||
{errors.key && (
|
||||
<p className="text-red-500 text-sm mt-1">{errors.key.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("template.subject", "Subject")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("template.subjectPlaceholder", "Subject")}
|
||||
{...register("subject", {
|
||||
required: t("template.subjectRequired", "Subject is required"),
|
||||
})}
|
||||
/>
|
||||
{errors.subject && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{errors.subject.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("template.englishName", "English Name")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("template.englishNamePlaceholder", "English Name")}
|
||||
{...register("name.en", {
|
||||
required: t(
|
||||
"template.englishNameRequired",
|
||||
"English name is required",
|
||||
),
|
||||
})}
|
||||
/>
|
||||
{errors.name?.en && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{errors.name.en.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("template.amharicName", "Amharic Name")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("template.amharicNamePlaceholder", "Amharic Name")}
|
||||
{...register("name.am", {
|
||||
required: t(
|
||||
"template.amharicNameRequired",
|
||||
"Amharic name is required",
|
||||
),
|
||||
})}
|
||||
/>
|
||||
{errors.name?.am && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{errors.name.am.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header Selection */}
|
||||
{headerOptions.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("template.header", "Header")}
|
||||
</label>
|
||||
<select
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
value={selectedHeaderId || ""}
|
||||
onChange={(e) => setValue("headerId", e.target.value)}
|
||||
>
|
||||
<option value="">{t("template.noHeader", "No Header")}</option>
|
||||
{headerOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer Selection */}
|
||||
{footerOptions.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("template.footer", "Footer")}
|
||||
</label>
|
||||
<select
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
value={selectedFooterId || ""}
|
||||
onChange={(e) => setValue("footerId", e.target.value)}
|
||||
>
|
||||
<option value="">{t("template.noFooter", "No Footer")}</option>
|
||||
{footerOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header Preview */}
|
||||
{selectedHeader && (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">
|
||||
{t("template.headerPreview", "Header Preview")}
|
||||
</p>
|
||||
<div
|
||||
className="bg-muted rounded-lg p-4 border-2 border-dashed border-muted-foreground/20"
|
||||
dangerouslySetInnerHTML={{ __html: selectedHeader }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Body Section */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("template.body", "Body")}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</label>
|
||||
<Controller
|
||||
name="body"
|
||||
control={control}
|
||||
rules={{ required: t("template.bodyRequired", "Body is required") }}
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<TemplateEditor
|
||||
value={field.value}
|
||||
onEditorChange={field.onChange}
|
||||
placeholders={[
|
||||
{
|
||||
key: "delegatorName",
|
||||
label: t("placeholder.delegatorName", "Delegator Name"),
|
||||
},
|
||||
{
|
||||
key: "delegatorDepartment",
|
||||
label: t(
|
||||
"placeholder.delegatorDepartment",
|
||||
"Delegator Department",
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "delegateeName",
|
||||
label: t("placeholder.delegateeName", "Delegatee Name"),
|
||||
},
|
||||
{
|
||||
key: "delegateeDepartment",
|
||||
label: t(
|
||||
"placeholder.delegateeDepartment",
|
||||
"Delegatee Department",
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "startDate",
|
||||
label: t("placeholder.startDate", "Start Date"),
|
||||
},
|
||||
{
|
||||
key: "endDate",
|
||||
label: t("placeholder.endDate", "End Date"),
|
||||
},
|
||||
{
|
||||
key: "startDateTime",
|
||||
label: t("placeholder.startDateTime", "Start Date & Time"),
|
||||
},
|
||||
{
|
||||
key: "endDateTime",
|
||||
label: t("placeholder.endDateTime", "End Date & Time"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<p className="text-red-500 text-sm mt-1">
|
||||
{fieldState.error?.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer Preview */}
|
||||
{selectedFooter && (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">
|
||||
{t("template.footerPreview", "Footer Preview")}
|
||||
</p>
|
||||
<div
|
||||
className="bg-muted rounded-lg p-4 border-2 border-dashed border-muted-foreground/20"
|
||||
dangerouslySetInnerHTML={{ __html: selectedFooter }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Sincerely Text */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t("template.sincerelyText", "Sincerely Text")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("template.sincerelyTextPlaceholder", "Sincerely Text")}
|
||||
{...register("sincerelyText")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-4 justify-end">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting
|
||||
? t("common.saving", "Saving...")
|
||||
: template
|
||||
? t("common.update", "Update")
|
||||
: t("common.save", "Save")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,154 +1,154 @@
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { FileText, Plus } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { useState } from "react";
|
||||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||
import { useLetterTemplates } from "@/user-management/hooks/useLetterTemplates";
|
||||
import { LetterTemplateForm } from "./LetterTemplateForm";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { t } from "i18next";
|
||||
import i18n from "@/i18n";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
interface Props {
|
||||
unitId: string;
|
||||
}
|
||||
|
||||
const LetterTemplatesCard = ({ unitId }: Props) => {
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const {
|
||||
letterTemplatesResponse,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
createLetterTemplate,
|
||||
updateLetterTemplate,
|
||||
isCreating,
|
||||
} = useLetterTemplates(unitId);
|
||||
const lang = i18n.language;
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
const selectedTemplate =
|
||||
letterTemplatesResponse?.items?.find((t) => t.id === selectedTemplateId) ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-900 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
{t("contentManagement.letterTemplate")}
|
||||
</CardTitle>
|
||||
<CardDescription className="dark:text-gray-400">
|
||||
{t("contentManagement.createMsg")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateForm(true)} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("contentManagement.addTemplate")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</>
|
||||
) : isError ? (
|
||||
<p className="text-sm text-red-600">
|
||||
{t("contentManagement.failedToLoadTemplates")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{letterTemplatesResponse && letterTemplatesResponse?.count > 0 && (
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setSelectedTemplateId(value);
|
||||
setShowCreateForm(false);
|
||||
}}
|
||||
value={selectedTemplateId ?? undefined}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("contentManagement.selectTemplate")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{letterTemplatesResponse.items.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
{lang === "en" ? template.name.en : template.name.am}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{(selectedTemplate || showCreateForm) && (
|
||||
<LetterTemplateForm
|
||||
unitId={unitId}
|
||||
isSubmitting={isCreating}
|
||||
template={selectedTemplate}
|
||||
onCancel={() => {
|
||||
setShowCreateForm(false);
|
||||
setSelectedTemplateId(null);
|
||||
}}
|
||||
onSubmitCreate={(values) => {
|
||||
if (selectedTemplate) {
|
||||
updateLetterTemplate(
|
||||
{ id: selectedTemplate.id, data: values },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("contentManagement.updateTemplate"));
|
||||
refetch();
|
||||
setShowCreateForm(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
} else {
|
||||
createLetterTemplate(values, {
|
||||
onSuccess: (newTemplate) => {
|
||||
toast.success(
|
||||
`${newTemplate.name.en} ${t("contentManagement.templateSuccessMsg")}`
|
||||
);
|
||||
refetch();
|
||||
setShowCreateForm(false);
|
||||
setSelectedTemplateId(newTemplate.id);
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LetterTemplatesCard;
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { FileText, Plus } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/common/ui/select";
|
||||
import { useState } from "react";
|
||||
import { Skeleton } from "@/shared/common/ui/skeleton";
|
||||
import { useLetterTemplates } from "@/user-management/hooks/useLetterTemplates";
|
||||
import { LetterTemplateForm } from "./LetterTemplateForm";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { t } from "i18next";
|
||||
import i18n from "@/i18n";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
interface Props {
|
||||
unitId: string;
|
||||
}
|
||||
|
||||
const LetterTemplatesCard = ({ unitId }: Props) => {
|
||||
const { handleError } = useErrorHandler(t);
|
||||
const {
|
||||
letterTemplatesResponse,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
createLetterTemplate,
|
||||
updateLetterTemplate,
|
||||
isCreating,
|
||||
} = useLetterTemplates(unitId);
|
||||
const lang = i18n.language;
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
const selectedTemplate =
|
||||
letterTemplatesResponse?.items?.find((t) => t.id === selectedTemplateId) ??
|
||||
null;
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-900 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
{t("contentManagement.letterTemplate")}
|
||||
</CardTitle>
|
||||
<CardDescription className="dark:text-gray-400">
|
||||
{t("contentManagement.createMsg")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateForm(true)} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("contentManagement.addTemplate")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</>
|
||||
) : isError ? (
|
||||
<p className="text-sm text-red-600">
|
||||
{t("contentManagement.failedToLoadTemplates")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{letterTemplatesResponse && letterTemplatesResponse?.count > 0 && (
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
setSelectedTemplateId(value);
|
||||
setShowCreateForm(false);
|
||||
}}
|
||||
value={selectedTemplateId ?? undefined}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("contentManagement.selectTemplate")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{letterTemplatesResponse.items.map((template) => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
{lang === "en" ? template.name.en : template.name.am}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{(selectedTemplate || showCreateForm) && (
|
||||
<LetterTemplateForm
|
||||
unitId={unitId}
|
||||
isSubmitting={isCreating}
|
||||
template={selectedTemplate}
|
||||
onCancel={() => {
|
||||
setShowCreateForm(false);
|
||||
setSelectedTemplateId(null);
|
||||
}}
|
||||
onSubmitCreate={(values) => {
|
||||
if (selectedTemplate) {
|
||||
updateLetterTemplate(
|
||||
{ id: selectedTemplate.id, data: values },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t("contentManagement.updateTemplate"));
|
||||
refetch();
|
||||
setShowCreateForm(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
} else {
|
||||
createLetterTemplate(values, {
|
||||
onSuccess: (newTemplate) => {
|
||||
toast.success(
|
||||
`${newTemplate.name.en} ${t("contentManagement.templateSuccessMsg")}`
|
||||
);
|
||||
refetch();
|
||||
setShowCreateForm(false);
|
||||
setSelectedTemplateId(newTemplate.id);
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LetterTemplatesCard;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,409 +1,409 @@
|
||||
// PrefixModal.tsx
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Label } from "@radix-ui/react-label";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CreatePrefixPayload,
|
||||
CreateSuffixPayload,
|
||||
prefixSuffixService,
|
||||
} from "@/user-management/services/api/prefixSuffixService";
|
||||
import { t } from "i18next";
|
||||
import { PrefixTabType } from "./PrefixAndSuffixCard";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useAddReferenceNumber,
|
||||
useGetReferenceNumbers,
|
||||
useUpdateExternalReferencePrefix,
|
||||
useUpdateInternalMemoReferencePrefix,
|
||||
} from "@/shared/hooks/useReferenceNumberPrefixes";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import { SingleSelect } from "@/shared/common/ui/single-select";
|
||||
|
||||
interface PrefixModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
unitId: string;
|
||||
cardType: string; // e.g., "prefix", "suffix", "prefixCC", "reference", "internalPrefix", etc.
|
||||
recordType: PrefixTabType;
|
||||
editingItem?: any;
|
||||
positions?: any[];
|
||||
}
|
||||
|
||||
export const PrefixModal = ({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
unitId,
|
||||
cardType,
|
||||
recordType,
|
||||
editingItem,
|
||||
positions = [],
|
||||
}: PrefixModalProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!editingItem;
|
||||
|
||||
// Form state
|
||||
const [nameAm, setNameAm] = useState("");
|
||||
const [nameEn, setNameEn] = useState("");
|
||||
const [positionId, setPositionId] = useState("");
|
||||
|
||||
// Determine if this is a reference type
|
||||
const isReference = [
|
||||
"reference",
|
||||
"externalReference",
|
||||
"internalMemoReference",
|
||||
].includes(cardType);
|
||||
const isPositionPrefix = recordType === "positionPrefix";
|
||||
const isCC = cardType.includes("CC");
|
||||
const isReferenceCard = cardType === "reference";
|
||||
const isExternalReferenceCard = cardType === "externalReference";
|
||||
const isInternalMemoReferenceCard = cardType === "internalMemoReference";
|
||||
const positionOptions = positions
|
||||
.filter((pos) => Boolean(pos?.id))
|
||||
.map((pos) => ({
|
||||
value: pos.id,
|
||||
label: pos.name?.en || pos.name?.am || pos.id,
|
||||
}));
|
||||
const positionRecordTypeKey =
|
||||
cardType === "internalPrefix"
|
||||
? "internal"
|
||||
: cardType === "externalPrefix"
|
||||
? "external"
|
||||
: cardType === "internalMemoPrefix"
|
||||
? "internal_memo"
|
||||
: editingItem?.recordTypeKey;
|
||||
|
||||
const { data: referenceNumbersData } = useGetReferenceNumbers(
|
||||
isReference ? unitId : "",
|
||||
);
|
||||
const addReferenceNumberMutation = useAddReferenceNumber();
|
||||
const updateInternalMemoReferenceMutation =
|
||||
useUpdateInternalMemoReferencePrefix();
|
||||
const updateExternalReferenceMutation = useUpdateExternalReferencePrefix();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
const referenceNumbers =
|
||||
((referenceNumbersData?.data as any)?.items?.[0] as any) ||
|
||||
referenceNumbersData?.data ||
|
||||
{};
|
||||
|
||||
const isArray = Array.isArray(referenceNumbers);
|
||||
const referenceNumberPrefix =
|
||||
isArray &&
|
||||
referenceNumbers?.find((rn: any) => rn.name === "referenceNumberPrefix");
|
||||
|
||||
const externalReferenceNumberPrefix =
|
||||
isArray &&
|
||||
referenceNumbers?.find(
|
||||
(rn: any) => rn.name === "externalReferenceNumberPrefix",
|
||||
);
|
||||
|
||||
const internalMemoReferenceNumberPrefix =
|
||||
isArray &&
|
||||
referenceNumbers?.find(
|
||||
(rn: any) => rn.name === "internalMemoReferenceNumberPrefix",
|
||||
);
|
||||
|
||||
// Populate form when editing
|
||||
useEffect(() => {
|
||||
if (editingItem) {
|
||||
if (isReference) {
|
||||
setNameAm(editingItem.name?.am || "");
|
||||
setNameEn(editingItem.name?.en || "");
|
||||
} else {
|
||||
setNameAm(editingItem.name?.am || "");
|
||||
setNameEn(editingItem.name?.en || "");
|
||||
}
|
||||
setPositionId(editingItem.positionId || "");
|
||||
} else {
|
||||
// Reset
|
||||
setNameAm("");
|
||||
setNameEn("");
|
||||
setPositionId("");
|
||||
}
|
||||
}, [editingItem, isReference]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReference || editingItem) return;
|
||||
|
||||
if (isReferenceCard) {
|
||||
setNameAm(referenceNumbers.referenceNumberPrefix || "");
|
||||
setNameEn(referenceNumbers.referenceNumberPrefix || "");
|
||||
return;
|
||||
}
|
||||
if (isExternalReferenceCard) {
|
||||
setNameAm(referenceNumbers.externalReferenceNumberPrefix || "");
|
||||
setNameEn(referenceNumbers.externalReferenceNumberPrefix || "");
|
||||
|
||||
return;
|
||||
}
|
||||
if (isInternalMemoReferenceCard) {
|
||||
setNameAm(referenceNumbers.internalMemoReferenceNumberPrefix || "");
|
||||
setNameEn(referenceNumbers.internalMemoReferenceNumberPrefix || "");
|
||||
}
|
||||
}, [
|
||||
editingItem,
|
||||
isReference,
|
||||
isReferenceCard,
|
||||
isExternalReferenceCard,
|
||||
isInternalMemoReferenceCard,
|
||||
referenceNumbers.referenceNumberPrefix,
|
||||
referenceNumbers.externalReferenceNumberPrefix,
|
||||
referenceNumbers.internalMemoReferenceNumberPrefix,
|
||||
]);
|
||||
|
||||
// Mutation for saving
|
||||
const { mutate: save, isPending } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const baseRecordTypeKey = recordType;
|
||||
const isPrefixOperation =
|
||||
isReference ||
|
||||
isPositionPrefix ||
|
||||
cardType === "prefix" ||
|
||||
cardType === "prefixCC" ||
|
||||
cardType === "internalPrefix" ||
|
||||
cardType === "externalPrefix" ||
|
||||
cardType === "internalMemoPrefix";
|
||||
|
||||
let prefixPayload: CreatePrefixPayload | undefined;
|
||||
let suffixPayload: CreateSuffixPayload | undefined;
|
||||
|
||||
if (isReference) {
|
||||
if (isReferenceCard) {
|
||||
await addReferenceNumberMutation.mutateAsync({
|
||||
unitId,
|
||||
payload: {
|
||||
referenceNumberPrefix: {
|
||||
am: nameAm,
|
||||
en: nameEn,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExternalReferenceCard) {
|
||||
await updateExternalReferenceMutation.mutateAsync({
|
||||
unitId,
|
||||
payload: {
|
||||
externalReferenceNumberPrefix: {
|
||||
am: nameAm,
|
||||
en: nameEn,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternalMemoReferenceCard) {
|
||||
await updateInternalMemoReferenceMutation.mutateAsync({
|
||||
unitId,
|
||||
payload: {
|
||||
internalMemoReferenceNumberPrefix: {
|
||||
am: nameAm,
|
||||
en: nameEn,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (isPositionPrefix) {
|
||||
if (
|
||||
positionRecordTypeKey !== "internal" &&
|
||||
positionRecordTypeKey !== "external" &&
|
||||
positionRecordTypeKey !== "internal_memo"
|
||||
) {
|
||||
throw new Error(
|
||||
"Position prefix requires internal, external, or internal_memo recordTypeKey",
|
||||
);
|
||||
}
|
||||
|
||||
prefixPayload = {
|
||||
unitId,
|
||||
recordTypeKey: positionRecordTypeKey,
|
||||
isForCC: false,
|
||||
positionId,
|
||||
name: { am: nameAm, en: nameEn },
|
||||
};
|
||||
} else {
|
||||
const commonPayload = {
|
||||
unitId,
|
||||
recordTypeKey: baseRecordTypeKey,
|
||||
isForCC: isCC,
|
||||
name: { am: nameAm, en: nameEn },
|
||||
};
|
||||
|
||||
if (cardType === "suffix" || cardType === "suffixCC") {
|
||||
suffixPayload = commonPayload;
|
||||
} else {
|
||||
prefixPayload = commonPayload;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
if (isPrefixOperation && prefixPayload) {
|
||||
return prefixSuffixService.editPrefix(editingItem.id, prefixPayload);
|
||||
}
|
||||
if (suffixPayload) {
|
||||
return prefixSuffixService.editSuffix(editingItem.id, suffixPayload);
|
||||
}
|
||||
throw new Error("Invalid prefix/suffix payload for update");
|
||||
}
|
||||
|
||||
if (isPrefixOperation && prefixPayload) {
|
||||
return prefixSuffixService.createPrefix(prefixPayload);
|
||||
}
|
||||
if (suffixPayload) {
|
||||
return prefixSuffixService.createSuffix(suffixPayload);
|
||||
}
|
||||
throw new Error("Invalid prefix/suffix payload for create");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["prefixSuffix"] });
|
||||
toast.success(t("msg.successfullyCompleted"));
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
save();
|
||||
};
|
||||
|
||||
const isValid = () => {
|
||||
if (isReference) return nameAm.trim() !== "" && nameEn.trim() !== "";
|
||||
|
||||
if (isPositionPrefix)
|
||||
return nameAm.trim() !== "" && nameEn.trim() !== "" && positionId;
|
||||
return nameAm.trim() !== "" && nameEn.trim() !== "";
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px] dark:bg-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="dark:text-white">
|
||||
{isEditing
|
||||
? t("contentManagement.edit")
|
||||
: t("contentManagement.add")}{" "}
|
||||
{t(`contentManagement.${cardType}`)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{isReference ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nameAm" className="dark:text-gray-200">
|
||||
{t("contentManagement.amharicName")} *
|
||||
</Label>
|
||||
<Input
|
||||
id="nameAm"
|
||||
value={nameAm}
|
||||
onChange={(e) => setNameAm(e.target.value)}
|
||||
placeholder={t("contentManagement.amharicName")}
|
||||
disabled={isPending}
|
||||
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nameEn" className="dark:text-gray-200">
|
||||
{t("contentManagement.englishName")} *
|
||||
</Label>
|
||||
<Input
|
||||
id="nameEn"
|
||||
value={nameEn}
|
||||
onChange={(e) => setNameEn(e.target.value)}
|
||||
placeholder={t("contentManagement.englishName")}
|
||||
disabled={isPending}
|
||||
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nameAm" className="dark:text-gray-200">
|
||||
{t("contentManagement.amharicName")} *
|
||||
</Label>
|
||||
<Input
|
||||
id="nameAm"
|
||||
value={nameAm}
|
||||
onChange={(e) => setNameAm(e.target.value)}
|
||||
placeholder={t("contentManagement.amharicName")}
|
||||
disabled={isPending}
|
||||
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nameEn" className="dark:text-gray-200">
|
||||
{t("contentManagement.englishName")} *
|
||||
</Label>
|
||||
<Input
|
||||
id="nameEn"
|
||||
value={nameEn}
|
||||
onChange={(e) => setNameEn(e.target.value)}
|
||||
placeholder={t("contentManagement.englishName")}
|
||||
disabled={isPending}
|
||||
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isPositionPrefix && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="position" className="dark:text-gray-200">
|
||||
{t("contentManagement.position")} *
|
||||
</Label>
|
||||
<SingleSelect
|
||||
options={positionOptions}
|
||||
value={positionId}
|
||||
onValueChange={setPositionId}
|
||||
placeholder={t("contentManagement.selectPosition")}
|
||||
className={isPending ? "pointer-events-none opacity-60" : ""}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("common.Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isValid() || isPending}
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
>
|
||||
{isPending
|
||||
? t("contentManagement.saving")
|
||||
: (isReferenceCard &&
|
||||
(referenceNumbers.referenceNumberPrefix ||
|
||||
referenceNumbers.externalReferenceNumberPrefix ||
|
||||
referenceNumbers.internalMemoReferenceNumberPrefix)) ||
|
||||
isEditing
|
||||
? t("common.update")
|
||||
: t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
// PrefixModal.tsx
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Label } from "@radix-ui/react-label";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CreatePrefixPayload,
|
||||
CreateSuffixPayload,
|
||||
prefixSuffixService,
|
||||
} from "@/user-management/services/api/prefixSuffixService";
|
||||
import { t } from "i18next";
|
||||
import { PrefixTabType } from "./PrefixAndSuffixCard";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
useAddReferenceNumber,
|
||||
useGetReferenceNumbers,
|
||||
useUpdateExternalReferencePrefix,
|
||||
useUpdateInternalMemoReferencePrefix,
|
||||
} from "@/shared/hooks/useReferenceNumberPrefixes";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
import { SingleSelect } from "@/shared/common/ui/single-select";
|
||||
|
||||
interface PrefixModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
unitId: string;
|
||||
cardType: string; // e.g., "prefix", "suffix", "prefixCC", "reference", "internalPrefix", etc.
|
||||
recordType: PrefixTabType;
|
||||
editingItem?: any;
|
||||
positions?: any[];
|
||||
}
|
||||
|
||||
export const PrefixModal = ({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
unitId,
|
||||
cardType,
|
||||
recordType,
|
||||
editingItem,
|
||||
positions = [],
|
||||
}: PrefixModalProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const isEditing = !!editingItem;
|
||||
|
||||
// Form state
|
||||
const [nameAm, setNameAm] = useState("");
|
||||
const [nameEn, setNameEn] = useState("");
|
||||
const [positionId, setPositionId] = useState("");
|
||||
|
||||
// Determine if this is a reference type
|
||||
const isReference = [
|
||||
"reference",
|
||||
"externalReference",
|
||||
"internalMemoReference",
|
||||
].includes(cardType);
|
||||
const isPositionPrefix = recordType === "positionPrefix";
|
||||
const isCC = cardType.includes("CC");
|
||||
const isReferenceCard = cardType === "reference";
|
||||
const isExternalReferenceCard = cardType === "externalReference";
|
||||
const isInternalMemoReferenceCard = cardType === "internalMemoReference";
|
||||
const positionOptions = positions
|
||||
.filter((pos) => Boolean(pos?.id))
|
||||
.map((pos) => ({
|
||||
value: pos.id,
|
||||
label: pos.name?.en || pos.name?.am || pos.id,
|
||||
}));
|
||||
const positionRecordTypeKey =
|
||||
cardType === "internalPrefix"
|
||||
? "internal"
|
||||
: cardType === "externalPrefix"
|
||||
? "external"
|
||||
: cardType === "internalMemoPrefix"
|
||||
? "internal_memo"
|
||||
: editingItem?.recordTypeKey;
|
||||
|
||||
const { data: referenceNumbersData } = useGetReferenceNumbers(
|
||||
isReference ? unitId : "",
|
||||
);
|
||||
const addReferenceNumberMutation = useAddReferenceNumber();
|
||||
const updateInternalMemoReferenceMutation =
|
||||
useUpdateInternalMemoReferencePrefix();
|
||||
const updateExternalReferenceMutation = useUpdateExternalReferencePrefix();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
const referenceNumbers =
|
||||
((referenceNumbersData?.data as any)?.items?.[0] as any) ||
|
||||
referenceNumbersData?.data ||
|
||||
{};
|
||||
|
||||
const isArray = Array.isArray(referenceNumbers);
|
||||
const referenceNumberPrefix =
|
||||
isArray &&
|
||||
referenceNumbers?.find((rn: any) => rn.name === "referenceNumberPrefix");
|
||||
|
||||
const externalReferenceNumberPrefix =
|
||||
isArray &&
|
||||
referenceNumbers?.find(
|
||||
(rn: any) => rn.name === "externalReferenceNumberPrefix",
|
||||
);
|
||||
|
||||
const internalMemoReferenceNumberPrefix =
|
||||
isArray &&
|
||||
referenceNumbers?.find(
|
||||
(rn: any) => rn.name === "internalMemoReferenceNumberPrefix",
|
||||
);
|
||||
|
||||
// Populate form when editing
|
||||
useEffect(() => {
|
||||
if (editingItem) {
|
||||
if (isReference) {
|
||||
setNameAm(editingItem.name?.am || "");
|
||||
setNameEn(editingItem.name?.en || "");
|
||||
} else {
|
||||
setNameAm(editingItem.name?.am || "");
|
||||
setNameEn(editingItem.name?.en || "");
|
||||
}
|
||||
setPositionId(editingItem.positionId || "");
|
||||
} else {
|
||||
// Reset
|
||||
setNameAm("");
|
||||
setNameEn("");
|
||||
setPositionId("");
|
||||
}
|
||||
}, [editingItem, isReference]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReference || editingItem) return;
|
||||
|
||||
if (isReferenceCard) {
|
||||
setNameAm(referenceNumbers.referenceNumberPrefix || "");
|
||||
setNameEn(referenceNumbers.referenceNumberPrefix || "");
|
||||
return;
|
||||
}
|
||||
if (isExternalReferenceCard) {
|
||||
setNameAm(referenceNumbers.externalReferenceNumberPrefix || "");
|
||||
setNameEn(referenceNumbers.externalReferenceNumberPrefix || "");
|
||||
|
||||
return;
|
||||
}
|
||||
if (isInternalMemoReferenceCard) {
|
||||
setNameAm(referenceNumbers.internalMemoReferenceNumberPrefix || "");
|
||||
setNameEn(referenceNumbers.internalMemoReferenceNumberPrefix || "");
|
||||
}
|
||||
}, [
|
||||
editingItem,
|
||||
isReference,
|
||||
isReferenceCard,
|
||||
isExternalReferenceCard,
|
||||
isInternalMemoReferenceCard,
|
||||
referenceNumbers.referenceNumberPrefix,
|
||||
referenceNumbers.externalReferenceNumberPrefix,
|
||||
referenceNumbers.internalMemoReferenceNumberPrefix,
|
||||
]);
|
||||
|
||||
// Mutation for saving
|
||||
const { mutate: save, isPending } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const baseRecordTypeKey = recordType;
|
||||
const isPrefixOperation =
|
||||
isReference ||
|
||||
isPositionPrefix ||
|
||||
cardType === "prefix" ||
|
||||
cardType === "prefixCC" ||
|
||||
cardType === "internalPrefix" ||
|
||||
cardType === "externalPrefix" ||
|
||||
cardType === "internalMemoPrefix";
|
||||
|
||||
let prefixPayload: CreatePrefixPayload | undefined;
|
||||
let suffixPayload: CreateSuffixPayload | undefined;
|
||||
|
||||
if (isReference) {
|
||||
if (isReferenceCard) {
|
||||
await addReferenceNumberMutation.mutateAsync({
|
||||
unitId,
|
||||
payload: {
|
||||
referenceNumberPrefix: {
|
||||
am: nameAm,
|
||||
en: nameEn,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExternalReferenceCard) {
|
||||
await updateExternalReferenceMutation.mutateAsync({
|
||||
unitId,
|
||||
payload: {
|
||||
externalReferenceNumberPrefix: {
|
||||
am: nameAm,
|
||||
en: nameEn,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternalMemoReferenceCard) {
|
||||
await updateInternalMemoReferenceMutation.mutateAsync({
|
||||
unitId,
|
||||
payload: {
|
||||
internalMemoReferenceNumberPrefix: {
|
||||
am: nameAm,
|
||||
en: nameEn,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (isPositionPrefix) {
|
||||
if (
|
||||
positionRecordTypeKey !== "internal" &&
|
||||
positionRecordTypeKey !== "external" &&
|
||||
positionRecordTypeKey !== "internal_memo"
|
||||
) {
|
||||
throw new Error(
|
||||
"Position prefix requires internal, external, or internal_memo recordTypeKey",
|
||||
);
|
||||
}
|
||||
|
||||
prefixPayload = {
|
||||
unitId,
|
||||
recordTypeKey: positionRecordTypeKey,
|
||||
isForCC: false,
|
||||
positionId,
|
||||
name: { am: nameAm, en: nameEn },
|
||||
};
|
||||
} else {
|
||||
const commonPayload = {
|
||||
unitId,
|
||||
recordTypeKey: baseRecordTypeKey,
|
||||
isForCC: isCC,
|
||||
name: { am: nameAm, en: nameEn },
|
||||
};
|
||||
|
||||
if (cardType === "suffix" || cardType === "suffixCC") {
|
||||
suffixPayload = commonPayload;
|
||||
} else {
|
||||
prefixPayload = commonPayload;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
if (isPrefixOperation && prefixPayload) {
|
||||
return prefixSuffixService.editPrefix(editingItem.id, prefixPayload);
|
||||
}
|
||||
if (suffixPayload) {
|
||||
return prefixSuffixService.editSuffix(editingItem.id, suffixPayload);
|
||||
}
|
||||
throw new Error("Invalid prefix/suffix payload for update");
|
||||
}
|
||||
|
||||
if (isPrefixOperation && prefixPayload) {
|
||||
return prefixSuffixService.createPrefix(prefixPayload);
|
||||
}
|
||||
if (suffixPayload) {
|
||||
return prefixSuffixService.createSuffix(suffixPayload);
|
||||
}
|
||||
throw new Error("Invalid prefix/suffix payload for create");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["prefixSuffix"] });
|
||||
toast.success(t("msg.successfullyCompleted"));
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
save();
|
||||
};
|
||||
|
||||
const isValid = () => {
|
||||
if (isReference) return nameAm.trim() !== "" && nameEn.trim() !== "";
|
||||
|
||||
if (isPositionPrefix)
|
||||
return nameAm.trim() !== "" && nameEn.trim() !== "" && positionId;
|
||||
return nameAm.trim() !== "" && nameEn.trim() !== "";
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px] dark:bg-gray-800">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="dark:text-white">
|
||||
{isEditing
|
||||
? t("contentManagement.edit")
|
||||
: t("contentManagement.add")}{" "}
|
||||
{t(`contentManagement.${cardType}`)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{isReference ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nameAm" className="dark:text-gray-200">
|
||||
{t("contentManagement.amharicName")} *
|
||||
</Label>
|
||||
<Input
|
||||
id="nameAm"
|
||||
value={nameAm}
|
||||
onChange={(e) => setNameAm(e.target.value)}
|
||||
placeholder={t("contentManagement.amharicName")}
|
||||
disabled={isPending}
|
||||
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nameEn" className="dark:text-gray-200">
|
||||
{t("contentManagement.englishName")} *
|
||||
</Label>
|
||||
<Input
|
||||
id="nameEn"
|
||||
value={nameEn}
|
||||
onChange={(e) => setNameEn(e.target.value)}
|
||||
placeholder={t("contentManagement.englishName")}
|
||||
disabled={isPending}
|
||||
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nameAm" className="dark:text-gray-200">
|
||||
{t("contentManagement.amharicName")} *
|
||||
</Label>
|
||||
<Input
|
||||
id="nameAm"
|
||||
value={nameAm}
|
||||
onChange={(e) => setNameAm(e.target.value)}
|
||||
placeholder={t("contentManagement.amharicName")}
|
||||
disabled={isPending}
|
||||
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nameEn" className="dark:text-gray-200">
|
||||
{t("contentManagement.englishName")} *
|
||||
</Label>
|
||||
<Input
|
||||
id="nameEn"
|
||||
value={nameEn}
|
||||
onChange={(e) => setNameEn(e.target.value)}
|
||||
placeholder={t("contentManagement.englishName")}
|
||||
disabled={isPending}
|
||||
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isPositionPrefix && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="position" className="dark:text-gray-200">
|
||||
{t("contentManagement.position")} *
|
||||
</Label>
|
||||
<SingleSelect
|
||||
options={positionOptions}
|
||||
value={positionId}
|
||||
onValueChange={setPositionId}
|
||||
placeholder={t("contentManagement.selectPosition")}
|
||||
className={isPending ? "pointer-events-none opacity-60" : ""}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("common.Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isValid() || isPending}
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
>
|
||||
{isPending
|
||||
? t("contentManagement.saving")
|
||||
: (isReferenceCard &&
|
||||
(referenceNumbers.referenceNumberPrefix ||
|
||||
referenceNumbers.externalReferenceNumberPrefix ||
|
||||
referenceNumbers.internalMemoReferenceNumberPrefix)) ||
|
||||
isEditing
|
||||
? t("common.update")
|
||||
: t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,246 +1,246 @@
|
||||
// PrefixSuffixTable.tsx
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Pencil, Trash2, X } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { prefixSuffixService } from "@/user-management/services/api/prefixSuffixService";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface PrefixSuffixTableProps {
|
||||
items: any[];
|
||||
isLoading: boolean;
|
||||
onEdit: (item: any) => void;
|
||||
onDelete: (id: string, type: string, recordType: string) => void;
|
||||
showPositionColumn?: boolean;
|
||||
showCountColumn?: boolean;
|
||||
positions?: any[];
|
||||
}
|
||||
|
||||
export const PrefixSuffixTable = ({
|
||||
items,
|
||||
isLoading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
showPositionColumn = false,
|
||||
showCountColumn = false,
|
||||
positions = [],
|
||||
}: PrefixSuffixTableProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const getPositionName = (positionId: string) => {
|
||||
const pos = positions.find((p) => p.id === positionId);
|
||||
return pos?.name?.en || pos?.name?.am || positionId;
|
||||
};
|
||||
const [countPopup, setCountPopup] = useState<{
|
||||
itemId: string;
|
||||
currentCount: number;
|
||||
} | null>(null);
|
||||
const [countValue, setCountValue] = useState("");
|
||||
|
||||
// 1. Helper to get count from either source
|
||||
const getCount = (item: any): number | undefined => {
|
||||
return item.recordSequences?.[0]?.count ?? item.count ?? undefined;
|
||||
};
|
||||
|
||||
// 2. Helper to get sequence ID from either source
|
||||
const getSequenceId = (item: any): string | undefined => {
|
||||
return item.recordSequences?.[0]?.id ?? item.sequenceId ?? undefined;
|
||||
};
|
||||
|
||||
// 3. Updated openCountPopup
|
||||
const openCountPopup = (item: any) => {
|
||||
const currentCount = getCount(item) ?? 0;
|
||||
const sequenceId = getSequenceId(item);
|
||||
|
||||
if (!sequenceId) {
|
||||
console.error("No sequence ID found for item:", item);
|
||||
return;
|
||||
}
|
||||
|
||||
setCountPopup({ itemId: sequenceId, currentCount });
|
||||
setCountValue(String(currentCount));
|
||||
};
|
||||
const closeCountPopup = () => {
|
||||
setCountPopup(null);
|
||||
setCountValue("");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["prefixes-by-position"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["unit-reference-numbers"],
|
||||
});
|
||||
};
|
||||
|
||||
const handleCountSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!countPopup) return;
|
||||
const newCount = parseInt(countValue, 10);
|
||||
if (isNaN(newCount) || newCount < 0) return;
|
||||
prefixSuffixService.updateCount(countPopup.itemId, newCount).then(() => {
|
||||
closeCountPopup();
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="overflow-x-auto border rounded-lg dark:border-gray-600">
|
||||
<table className="min-w-full text-sm text-left">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700 border-b dark:border-gray-600">
|
||||
<tr>
|
||||
<th className="px-4 py-3 dark:text-white">#</th>
|
||||
<th className="px-4 py-3 dark:text-white">{t("header.amharic")}</th>
|
||||
<th className="px-4 py-3 dark:text-white">{t("header.english")}</th>
|
||||
<th className="px-4 py-3 dark:text-white">
|
||||
{t("contentManagement.type")}
|
||||
</th>
|
||||
{showPositionColumn && (
|
||||
<th className="px-4 py-3 dark:text-white">
|
||||
{t("contentManagement.position")}
|
||||
</th>
|
||||
)}
|
||||
{showCountColumn && (
|
||||
<th className="px-4 py-3 dark:text-white">
|
||||
{t("contentManagement.count")}
|
||||
</th>
|
||||
)}
|
||||
<th className="px-4 py-3 dark:text-white">
|
||||
{t("userRecord.Actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={showPositionColumn ? 6 : 5}
|
||||
className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{t("contentManagement.loading")}
|
||||
</td>
|
||||
</tr>
|
||||
) : items.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={showPositionColumn ? 6 : 5}
|
||||
className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{t("contentManagement.noRec")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((item, idx) => {
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600">
|
||||
<td className="px-4 py-3 dark:text-gray-300">{idx + 1}</td>
|
||||
<td className="px-4 py-3 dark:text-gray-300">
|
||||
{item.name?.am || "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 dark:text-gray-300">
|
||||
{item.name?.en || "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 capitalize dark:text-gray-300">
|
||||
{item.type}
|
||||
{item.isForCC && (
|
||||
<span className="ml-1 text-xs text-purple-600 dark:text-purple-400">
|
||||
(CC)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{showPositionColumn && (
|
||||
<td className="px-4 py-3 dark:text-gray-300">
|
||||
{item.positionId ? getPositionName(item.positionId) : "-"}
|
||||
</td>
|
||||
)}
|
||||
{showCountColumn && (
|
||||
<td className="px-4 py-3 dark:text-gray-300">
|
||||
{(() => {
|
||||
const count = getCount(item);
|
||||
if (count === undefined) return "-";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => openCountPopup(item)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 underline cursor-pointer">
|
||||
{count}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
)}
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(item)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
onClick={() =>
|
||||
onDelete(item.id, item.type, item.recordTypeKey)
|
||||
}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Count Update Popup */}
|
||||
{countPopup && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 w-80">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold dark:text-white">
|
||||
{t("contentManagement.updateCount")}
|
||||
</h3>
|
||||
<button
|
||||
onClick={closeCountPopup}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCountSubmit}>
|
||||
<div className="space-y-2 mb-4">
|
||||
<Label htmlFor="countValue" className="dark:text-gray-200">
|
||||
{t("contentManagement.value")} *
|
||||
</Label>
|
||||
<Input
|
||||
id="countValue"
|
||||
type="number"
|
||||
min="0"
|
||||
value={countValue}
|
||||
onChange={(e) => setCountValue(e.target.value)}
|
||||
placeholder={t("contentManagement.enterValue")}
|
||||
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("contentManagement.currentCount")}:{" "}
|
||||
{countPopup.currentCount}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeCountPopup}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("common.submit")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// PrefixSuffixTable.tsx
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Pencil, Trash2, X } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { prefixSuffixService } from "@/user-management/services/api/prefixSuffixService";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface PrefixSuffixTableProps {
|
||||
items: any[];
|
||||
isLoading: boolean;
|
||||
onEdit: (item: any) => void;
|
||||
onDelete: (id: string, type: string, recordType: string) => void;
|
||||
showPositionColumn?: boolean;
|
||||
showCountColumn?: boolean;
|
||||
positions?: any[];
|
||||
}
|
||||
|
||||
export const PrefixSuffixTable = ({
|
||||
items,
|
||||
isLoading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
showPositionColumn = false,
|
||||
showCountColumn = false,
|
||||
positions = [],
|
||||
}: PrefixSuffixTableProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const getPositionName = (positionId: string) => {
|
||||
const pos = positions.find((p) => p.id === positionId);
|
||||
return pos?.name?.en || pos?.name?.am || positionId;
|
||||
};
|
||||
const [countPopup, setCountPopup] = useState<{
|
||||
itemId: string;
|
||||
currentCount: number;
|
||||
} | null>(null);
|
||||
const [countValue, setCountValue] = useState("");
|
||||
|
||||
// 1. Helper to get count from either source
|
||||
const getCount = (item: any): number | undefined => {
|
||||
return item.recordSequences?.[0]?.count ?? item.count ?? undefined;
|
||||
};
|
||||
|
||||
// 2. Helper to get sequence ID from either source
|
||||
const getSequenceId = (item: any): string | undefined => {
|
||||
return item.recordSequences?.[0]?.id ?? item.sequenceId ?? undefined;
|
||||
};
|
||||
|
||||
// 3. Updated openCountPopup
|
||||
const openCountPopup = (item: any) => {
|
||||
const currentCount = getCount(item) ?? 0;
|
||||
const sequenceId = getSequenceId(item);
|
||||
|
||||
if (!sequenceId) {
|
||||
console.error("No sequence ID found for item:", item);
|
||||
return;
|
||||
}
|
||||
|
||||
setCountPopup({ itemId: sequenceId, currentCount });
|
||||
setCountValue(String(currentCount));
|
||||
};
|
||||
const closeCountPopup = () => {
|
||||
setCountPopup(null);
|
||||
setCountValue("");
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["prefixes-by-position"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["unit-reference-numbers"],
|
||||
});
|
||||
};
|
||||
|
||||
const handleCountSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!countPopup) return;
|
||||
const newCount = parseInt(countValue, 10);
|
||||
if (isNaN(newCount) || newCount < 0) return;
|
||||
prefixSuffixService.updateCount(countPopup.itemId, newCount).then(() => {
|
||||
closeCountPopup();
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className="overflow-x-auto border rounded-lg dark:border-gray-600">
|
||||
<table className="min-w-full text-sm text-left">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700 border-b dark:border-gray-600">
|
||||
<tr>
|
||||
<th className="px-4 py-3 dark:text-white">#</th>
|
||||
<th className="px-4 py-3 dark:text-white">{t("header.amharic")}</th>
|
||||
<th className="px-4 py-3 dark:text-white">{t("header.english")}</th>
|
||||
<th className="px-4 py-3 dark:text-white">
|
||||
{t("contentManagement.type")}
|
||||
</th>
|
||||
{showPositionColumn && (
|
||||
<th className="px-4 py-3 dark:text-white">
|
||||
{t("contentManagement.position")}
|
||||
</th>
|
||||
)}
|
||||
{showCountColumn && (
|
||||
<th className="px-4 py-3 dark:text-white">
|
||||
{t("contentManagement.count")}
|
||||
</th>
|
||||
)}
|
||||
<th className="px-4 py-3 dark:text-white">
|
||||
{t("userRecord.Actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={showPositionColumn ? 6 : 5}
|
||||
className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{t("contentManagement.loading")}
|
||||
</td>
|
||||
</tr>
|
||||
) : items.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={showPositionColumn ? 6 : 5}
|
||||
className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{t("contentManagement.noRec")}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((item, idx) => {
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600">
|
||||
<td className="px-4 py-3 dark:text-gray-300">{idx + 1}</td>
|
||||
<td className="px-4 py-3 dark:text-gray-300">
|
||||
{item.name?.am || "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 dark:text-gray-300">
|
||||
{item.name?.en || "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 capitalize dark:text-gray-300">
|
||||
{item.type}
|
||||
{item.isForCC && (
|
||||
<span className="ml-1 text-xs text-purple-600 dark:text-purple-400">
|
||||
(CC)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{showPositionColumn && (
|
||||
<td className="px-4 py-3 dark:text-gray-300">
|
||||
{item.positionId ? getPositionName(item.positionId) : "-"}
|
||||
</td>
|
||||
)}
|
||||
{showCountColumn && (
|
||||
<td className="px-4 py-3 dark:text-gray-300">
|
||||
{(() => {
|
||||
const count = getCount(item);
|
||||
if (count === undefined) return "-";
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => openCountPopup(item)}
|
||||
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 underline cursor-pointer">
|
||||
{count}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
)}
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(item)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
onClick={() =>
|
||||
onDelete(item.id, item.type, item.recordTypeKey)
|
||||
}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Count Update Popup */}
|
||||
{countPopup && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 w-80">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold dark:text-white">
|
||||
{t("contentManagement.updateCount")}
|
||||
</h3>
|
||||
<button
|
||||
onClick={closeCountPopup}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCountSubmit}>
|
||||
<div className="space-y-2 mb-4">
|
||||
<Label htmlFor="countValue" className="dark:text-gray-200">
|
||||
{t("contentManagement.value")} *
|
||||
</Label>
|
||||
<Input
|
||||
id="countValue"
|
||||
type="number"
|
||||
min="0"
|
||||
value={countValue}
|
||||
onChange={(e) => setCountValue(e.target.value)}
|
||||
placeholder={t("contentManagement.enterValue")}
|
||||
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("contentManagement.currentCount")}:{" "}
|
||||
{countPopup.currentCount}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeCountPopup}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("common.submit")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,223 +1,223 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { FileText, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Activity } from "./ContentManagement";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { listAuditLogExtensions, AuditLogExtensionItem } from "@/shared/services/audit/audit.api";
|
||||
|
||||
interface Props {
|
||||
activities?: Activity[];
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
const RecentActivitiesCard = ({ activities = [], pageSize = 10 }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLogExtensionItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const [allLogs, setAllLogs] = useState<AuditLogExtensionItem[]>([]);
|
||||
|
||||
const fetchAuditLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch all 1000 records at once
|
||||
const data = await listAuditLogExtensions(
|
||||
"/audit-log-extensions/audit/unitAdmin",
|
||||
{
|
||||
skip: 0,
|
||||
take: 1000,
|
||||
orderBy: "createdAt:DESC",
|
||||
}
|
||||
);
|
||||
|
||||
const logs = data.items || [];
|
||||
setAllLogs(logs);
|
||||
setTotalCount(logs.length);
|
||||
setCurrentPage(1); // Reset to first page
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching audit logs:", error);
|
||||
// Silently fail for now - don't show error toast on load
|
||||
// as this is a supplementary feature
|
||||
setAllLogs([]);
|
||||
setTotalCount(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuditLogs();
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchAuditLogs, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchAuditLogs]);
|
||||
|
||||
// Paginate the allLogs whenever currentPage changes
|
||||
useEffect(() => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
setAuditLogs(allLogs.slice(startIndex, endIndex));
|
||||
}, [currentPage, pageSize, allLogs]);
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const hasNextPage = currentPage < totalPages;
|
||||
const hasPrevPage = currentPage > 1;
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-gray-50 to-slate-50 dark:from-gray-800 dark:to-gray-900 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="flex items-center dark:text-white">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
{t("dashboard.recentActivities", "Recent Activities")}
|
||||
</CardTitle>
|
||||
<CardDescription className="dark:text-gray-400">
|
||||
{t("organization.recentContentActivities", "Recent content management activities")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => fetchAuditLogs()}
|
||||
disabled={loading}
|
||||
className="ml-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Display audit logs if available */}
|
||||
{auditLogs.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{auditLogs.map((log, idx) => {
|
||||
const userName = i18n.language === "am" ? log.user.name.am : log.user.name.en;
|
||||
const entityLabel = log.entityName.replace(/_/g, " ");
|
||||
const timestamp = new Date(log.createdAt).toLocaleString(
|
||||
i18n.language,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={log.id || idx}
|
||||
className="p-3 rounded-lg bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:shadow-sm transition-shadow"
|
||||
>
|
||||
<p className="text-sm text-gray-900 dark:text-gray-100">
|
||||
<span className="font-semibold">{userName}</span>
|
||||
{" "}
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
{log.queryMethod.toLowerCase() === "insert"
|
||||
? "created"
|
||||
: log.queryMethod.toLowerCase() === "update"
|
||||
? "updated"
|
||||
: log.queryMethod.toLowerCase() === "delete"
|
||||
? "deleted"
|
||||
: log.queryMethod.toLowerCase()}
|
||||
</span>
|
||||
{" "}
|
||||
<span className="text-gray-900 dark:text-gray-100 font-medium">
|
||||
{entityLabel}
|
||||
</span>
|
||||
{" "}
|
||||
<span className="text-gray-500 dark:text-gray-400">at {timestamp}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : activities.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{activities.map(({ id, type, description, timestamp }) => (
|
||||
<div key={id} className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium capitalize dark:text-white">{type}</span>
|
||||
<span className="text-sm dark:text-gray-300">{description}</span>
|
||||
<span className="text-xs text-muted-foreground dark:text-gray-400">
|
||||
{formatDate(timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground dark:text-gray-400 text-center py-8">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
<span>{t("common.loading", "Loading...")}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("auditLog.noRecords", "No recent activities.")
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
{auditLogs.length > 0 && (
|
||||
<CardFooter className="flex items-center justify-between border-t border-gray-200 dark:border-gray-600 pt-4">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{t("common.showing", "Showing")} {(currentPage - 1) * pageSize + 1}-
|
||||
{Math.min(currentPage * pageSize, totalCount)} {t("common.of", "of")} {totalCount}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={!hasPrevPage || loading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 px-2">
|
||||
{currentPage} / {totalPages || 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={!hasNextPage || loading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecentActivitiesCard;
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { FileText, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Activity } from "./ContentManagement";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "i18next";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { listAuditLogExtensions, AuditLogExtensionItem } from "@/shared/services/audit/audit.api";
|
||||
|
||||
interface Props {
|
||||
activities?: Activity[];
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
const RecentActivitiesCard = ({ activities = [], pageSize = 10 }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLogExtensionItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const [allLogs, setAllLogs] = useState<AuditLogExtensionItem[]>([]);
|
||||
|
||||
const fetchAuditLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch all 1000 records at once
|
||||
const data = await listAuditLogExtensions(
|
||||
"/audit-log-extensions/audit/unitAdmin",
|
||||
{
|
||||
skip: 0,
|
||||
take: 1000,
|
||||
orderBy: "createdAt:DESC",
|
||||
}
|
||||
);
|
||||
|
||||
const logs = data.items || [];
|
||||
setAllLogs(logs);
|
||||
setTotalCount(logs.length);
|
||||
setCurrentPage(1); // Reset to first page
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching audit logs:", error);
|
||||
// Silently fail for now - don't show error toast on load
|
||||
// as this is a supplementary feature
|
||||
setAllLogs([]);
|
||||
setTotalCount(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuditLogs();
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchAuditLogs, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchAuditLogs]);
|
||||
|
||||
// Paginate the allLogs whenever currentPage changes
|
||||
useEffect(() => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
setAuditLogs(allLogs.slice(startIndex, endIndex));
|
||||
}, [currentPage, pageSize, allLogs]);
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const hasNextPage = currentPage < totalPages;
|
||||
const hasPrevPage = currentPage > 1;
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-gray-50 to-slate-50 dark:from-gray-800 dark:to-gray-900 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="flex items-center dark:text-white">
|
||||
<FileText className="h-5 w-5 mr-2" />
|
||||
{t("dashboard.recentActivities", "Recent Activities")}
|
||||
</CardTitle>
|
||||
<CardDescription className="dark:text-gray-400">
|
||||
{t("organization.recentContentActivities", "Recent content management activities")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => fetchAuditLogs()}
|
||||
disabled={loading}
|
||||
className="ml-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Display audit logs if available */}
|
||||
{auditLogs.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{auditLogs.map((log, idx) => {
|
||||
const userName = i18n.language === "am" ? log.user.name.am : log.user.name.en;
|
||||
const entityLabel = log.entityName.replace(/_/g, " ");
|
||||
const timestamp = new Date(log.createdAt).toLocaleString(
|
||||
i18n.language,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={log.id || idx}
|
||||
className="p-3 rounded-lg bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:shadow-sm transition-shadow"
|
||||
>
|
||||
<p className="text-sm text-gray-900 dark:text-gray-100">
|
||||
<span className="font-semibold">{userName}</span>
|
||||
{" "}
|
||||
<span className="text-gray-600 dark:text-gray-300">
|
||||
{log.queryMethod.toLowerCase() === "insert"
|
||||
? "created"
|
||||
: log.queryMethod.toLowerCase() === "update"
|
||||
? "updated"
|
||||
: log.queryMethod.toLowerCase() === "delete"
|
||||
? "deleted"
|
||||
: log.queryMethod.toLowerCase()}
|
||||
</span>
|
||||
{" "}
|
||||
<span className="text-gray-900 dark:text-gray-100 font-medium">
|
||||
{entityLabel}
|
||||
</span>
|
||||
{" "}
|
||||
<span className="text-gray-500 dark:text-gray-400">at {timestamp}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : activities.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{activities.map(({ id, type, description, timestamp }) => (
|
||||
<div key={id} className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium capitalize dark:text-white">{type}</span>
|
||||
<span className="text-sm dark:text-gray-300">{description}</span>
|
||||
<span className="text-xs text-muted-foreground dark:text-gray-400">
|
||||
{formatDate(timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground dark:text-gray-400 text-center py-8">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
<span>{t("common.loading", "Loading...")}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("auditLog.noRecords", "No recent activities.")
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
{auditLogs.length > 0 && (
|
||||
<CardFooter className="flex items-center justify-between border-t border-gray-200 dark:border-gray-600 pt-4">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{t("common.showing", "Showing")} {(currentPage - 1) * pageSize + 1}-
|
||||
{Math.min(currentPage * pageSize, totalCount)} {t("common.of", "of")} {totalCount}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={!hasPrevPage || loading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 px-2">
|
||||
{currentPage} / {totalPages || 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={!hasNextPage || loading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecentActivitiesCard;
|
||||
|
||||
@@ -1,351 +1,351 @@
|
||||
// components/record-tag-selector.tsx
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { X, Check, ChevronsUpDown, Tag, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/common/ui/popover";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/shared/common/ui/command";
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────
|
||||
|
||||
export interface TagSelectorProps {
|
||||
/** Unit ID to fetch tags from */
|
||||
unitId: string;
|
||||
/** Currently selected tag IDs */
|
||||
selectedTagIds: string[];
|
||||
/** Callback when selection changes */
|
||||
onChange: (tagIds: string[]) => void;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Allow multiple selection */
|
||||
multiple?: boolean;
|
||||
/** Disable the selector */
|
||||
disabled?: boolean;
|
||||
/** Custom className */
|
||||
className?: string;
|
||||
/** Maximum number of tags to show before collapsing */
|
||||
maxDisplayTags?: number;
|
||||
/** Optional error state */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ─── Component ─────────────────────────────────────────
|
||||
|
||||
export function RecordTagSelector({
|
||||
unitId,
|
||||
selectedTagIds,
|
||||
onChange,
|
||||
placeholder = "Select tags...",
|
||||
multiple = true,
|
||||
disabled = false,
|
||||
className,
|
||||
maxDisplayTags = 3,
|
||||
error,
|
||||
}: TagSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const localizedName = useLocalizedName();
|
||||
const { recordTagsList, isLoadingRecordTagsList, isErrorRecordTagsList } =
|
||||
useRecordTags({ unitId });
|
||||
|
||||
const tags = recordTagsList?.items ?? [];
|
||||
const selectedTags = tags.filter((tag) => selectedTagIds.includes(tag.id));
|
||||
|
||||
// Toggle tag selection
|
||||
const toggleTag = React.useCallback(
|
||||
(tagId: string) => {
|
||||
if (multiple) {
|
||||
onChange(
|
||||
selectedTagIds.includes(tagId)
|
||||
? selectedTagIds.filter((id) => id !== tagId)
|
||||
: [...selectedTagIds, tagId],
|
||||
);
|
||||
} else {
|
||||
onChange(selectedTagIds.includes(tagId) ? [] : [tagId]);
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[multiple, onChange, selectedTagIds],
|
||||
);
|
||||
|
||||
// Remove a specific tag
|
||||
const removeTag = React.useCallback(
|
||||
(e: React.MouseEvent, tagId: string) => {
|
||||
e.stopPropagation();
|
||||
onChange(selectedTagIds.filter((id) => id !== tagId));
|
||||
},
|
||||
[onChange, selectedTagIds],
|
||||
);
|
||||
|
||||
// Clear all selections
|
||||
const clearAll = React.useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange([]);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || isLoadingRecordTagsList}
|
||||
className={cn(
|
||||
"w-full justify-between min-h-[40px] h-auto px-3 py-2",
|
||||
!multiple && selectedTags.length > 0 && "justify-start gap-2",
|
||||
error && "border-destructive ring-destructive",
|
||||
"hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{isLoadingRecordTagsList ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">{t("common.loading")}</span>
|
||||
</div>
|
||||
) : selectedTags.length === 0 ? (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{placeholder}
|
||||
</span>
|
||||
) : multiple ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 flex-1">
|
||||
{selectedTags.slice(0, maxDisplayTags).map((tag: any) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant="secondary"
|
||||
className="gap-1 px-2 py-0.5 text-xs font-medium cursor-default"
|
||||
style={{
|
||||
backgroundColor: tag.color ? `${tag.color}20` : undefined,
|
||||
color: tag.color,
|
||||
borderColor: tag.color,
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
<X
|
||||
className="h-3 w-3 cursor-pointer hover:text-destructive"
|
||||
onClick={(e) => removeTag(e, tag.id)}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
{selectedTags.length > maxDisplayTags && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{selectedTags.length - maxDisplayTags}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<span className="text-sm">
|
||||
{localizedName(selectedTags[0].name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||||
{selectedTags.length > 0 && !disabled && (
|
||||
<X
|
||||
className="h-4 w-4 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={clearAll}
|
||||
/>
|
||||
)}
|
||||
<ChevronsUpDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className="w-[--radix-popover-trigger-width] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("common.search") || "Search tags..."}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isErrorRecordTagsList ? (
|
||||
<div className="py-6 text-center text-sm text-destructive">
|
||||
{t("common.errorLoading")}
|
||||
</div>
|
||||
) : (
|
||||
t("common.noResults") || "No tags found."
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
<CommandGroup>
|
||||
{tags.map((tag) => {
|
||||
const isSelected = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<CommandItem
|
||||
key={tag.id}
|
||||
value={tag.id}
|
||||
onSelect={() => toggleTag(tag.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50",
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="h-3 w-3" />}
|
||||
</div>
|
||||
|
||||
<span className="flex-1 text-sm">
|
||||
{localizedName(tag.name)}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Single Select Variant ─────────────────────────────
|
||||
|
||||
export function RecordTagSelectorSingle(
|
||||
props: Omit<TagSelectorProps, "multiple" | "maxDisplayTags">,
|
||||
) {
|
||||
return <RecordTagSelector {...props} multiple={false} />;
|
||||
}
|
||||
|
||||
// ─── Display Only (Read) Component ─────────────────────
|
||||
|
||||
export interface TagListProps {
|
||||
tagIds: string[];
|
||||
unitId: string;
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export function RecordTagList({
|
||||
tagIds,
|
||||
unitId,
|
||||
className,
|
||||
size = "md",
|
||||
}: TagListProps) {
|
||||
const localizedName = useLocalizedName();
|
||||
const { recordTagsList, isLoadingRecordTagsList } = useRecordTags({ unitId });
|
||||
const tags = recordTagsList?.items ?? [];
|
||||
const selectedTags = tags.filter((tag) => tagIds.includes(tag.id));
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "text-[10px] px-1.5 py-0",
|
||||
md: "text-xs px-2 py-0.5",
|
||||
lg: "text-sm px-2.5 py-1",
|
||||
};
|
||||
|
||||
if (isLoadingRecordTagsList) {
|
||||
return <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />;
|
||||
}
|
||||
|
||||
if (selectedTags.length === 0) {
|
||||
return <span className="text-xs text-muted-foreground">—</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap gap-1.5", className)}>
|
||||
{selectedTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant="outline"
|
||||
className={cn("font-medium gap-1.5", sizeClasses[size])}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" />
|
||||
{localizedName(tag.name)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Create Tag Dialog Integration ─────────────────────
|
||||
|
||||
export interface TagSelectorWithCreateProps extends TagSelectorProps {
|
||||
onCreateTag?: (name: string) => void;
|
||||
isCreatingTag?: boolean;
|
||||
}
|
||||
|
||||
export function RecordTagSelectorWithCreate({
|
||||
onCreateTag,
|
||||
isCreatingTag,
|
||||
...props
|
||||
}: TagSelectorWithCreateProps) {
|
||||
const { t } = useTranslation();
|
||||
const [newTagName, setNewTagName] = React.useState("");
|
||||
|
||||
const handleCreate = React.useCallback(() => {
|
||||
if (newTagName.trim() && onCreateTag) {
|
||||
onCreateTag(newTagName.trim());
|
||||
setNewTagName("");
|
||||
}
|
||||
}, [newTagName, onCreateTag]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<RecordTagSelector {...props} />
|
||||
|
||||
{onCreateTag && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Tag className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
placeholder={t("tags.createPlaceholder") || "Create new tag..."}
|
||||
className="w-full h-8 pl-8 pr-3 text-xs rounded-md border border-input bg-background
|
||||
focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={handleCreate}
|
||||
disabled={!newTagName.trim() || isCreatingTag}
|
||||
>
|
||||
{isCreatingTag ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
t("common.create") || "Create"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// components/record-tag-selector.tsx
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { X, Check, ChevronsUpDown, Tag, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/common/ui/popover";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Badge } from "@/shared/common/ui/badge";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/shared/common/ui/command";
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────
|
||||
|
||||
export interface TagSelectorProps {
|
||||
/** Unit ID to fetch tags from */
|
||||
unitId: string;
|
||||
/** Currently selected tag IDs */
|
||||
selectedTagIds: string[];
|
||||
/** Callback when selection changes */
|
||||
onChange: (tagIds: string[]) => void;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Allow multiple selection */
|
||||
multiple?: boolean;
|
||||
/** Disable the selector */
|
||||
disabled?: boolean;
|
||||
/** Custom className */
|
||||
className?: string;
|
||||
/** Maximum number of tags to show before collapsing */
|
||||
maxDisplayTags?: number;
|
||||
/** Optional error state */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ─── Component ─────────────────────────────────────────
|
||||
|
||||
export function RecordTagSelector({
|
||||
unitId,
|
||||
selectedTagIds,
|
||||
onChange,
|
||||
placeholder = "Select tags...",
|
||||
multiple = true,
|
||||
disabled = false,
|
||||
className,
|
||||
maxDisplayTags = 3,
|
||||
error,
|
||||
}: TagSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const localizedName = useLocalizedName();
|
||||
const { recordTagsList, isLoadingRecordTagsList, isErrorRecordTagsList } =
|
||||
useRecordTags({ unitId });
|
||||
|
||||
const tags = recordTagsList?.items ?? [];
|
||||
const selectedTags = tags.filter((tag) => selectedTagIds.includes(tag.id));
|
||||
|
||||
// Toggle tag selection
|
||||
const toggleTag = React.useCallback(
|
||||
(tagId: string) => {
|
||||
if (multiple) {
|
||||
onChange(
|
||||
selectedTagIds.includes(tagId)
|
||||
? selectedTagIds.filter((id) => id !== tagId)
|
||||
: [...selectedTagIds, tagId],
|
||||
);
|
||||
} else {
|
||||
onChange(selectedTagIds.includes(tagId) ? [] : [tagId]);
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[multiple, onChange, selectedTagIds],
|
||||
);
|
||||
|
||||
// Remove a specific tag
|
||||
const removeTag = React.useCallback(
|
||||
(e: React.MouseEvent, tagId: string) => {
|
||||
e.stopPropagation();
|
||||
onChange(selectedTagIds.filter((id) => id !== tagId));
|
||||
},
|
||||
[onChange, selectedTagIds],
|
||||
);
|
||||
|
||||
// Clear all selections
|
||||
const clearAll = React.useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange([]);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || isLoadingRecordTagsList}
|
||||
className={cn(
|
||||
"w-full justify-between min-h-[40px] h-auto px-3 py-2",
|
||||
!multiple && selectedTags.length > 0 && "justify-start gap-2",
|
||||
error && "border-destructive ring-destructive",
|
||||
"hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{isLoadingRecordTagsList ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">{t("common.loading")}</span>
|
||||
</div>
|
||||
) : selectedTags.length === 0 ? (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{placeholder}
|
||||
</span>
|
||||
) : multiple ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 flex-1">
|
||||
{selectedTags.slice(0, maxDisplayTags).map((tag: any) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant="secondary"
|
||||
className="gap-1 px-2 py-0.5 text-xs font-medium cursor-default"
|
||||
style={{
|
||||
backgroundColor: tag.color ? `${tag.color}20` : undefined,
|
||||
color: tag.color,
|
||||
borderColor: tag.color,
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
<X
|
||||
className="h-3 w-3 cursor-pointer hover:text-destructive"
|
||||
onClick={(e) => removeTag(e, tag.id)}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
{selectedTags.length > maxDisplayTags && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{selectedTags.length - maxDisplayTags}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<span className="text-sm">
|
||||
{localizedName(selectedTags[0].name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||||
{selectedTags.length > 0 && !disabled && (
|
||||
<X
|
||||
className="h-4 w-4 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={clearAll}
|
||||
/>
|
||||
)}
|
||||
<ChevronsUpDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className="w-[--radix-popover-trigger-width] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("common.search") || "Search tags..."}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{isErrorRecordTagsList ? (
|
||||
<div className="py-6 text-center text-sm text-destructive">
|
||||
{t("common.errorLoading")}
|
||||
</div>
|
||||
) : (
|
||||
t("common.noResults") || "No tags found."
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
<CommandGroup>
|
||||
{tags.map((tag) => {
|
||||
const isSelected = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<CommandItem
|
||||
key={tag.id}
|
||||
value={tag.id}
|
||||
onSelect={() => toggleTag(tag.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50",
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="h-3 w-3" />}
|
||||
</div>
|
||||
|
||||
<span className="flex-1 text-sm">
|
||||
{localizedName(tag.name)}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Single Select Variant ─────────────────────────────
|
||||
|
||||
export function RecordTagSelectorSingle(
|
||||
props: Omit<TagSelectorProps, "multiple" | "maxDisplayTags">,
|
||||
) {
|
||||
return <RecordTagSelector {...props} multiple={false} />;
|
||||
}
|
||||
|
||||
// ─── Display Only (Read) Component ─────────────────────
|
||||
|
||||
export interface TagListProps {
|
||||
tagIds: string[];
|
||||
unitId: string;
|
||||
className?: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export function RecordTagList({
|
||||
tagIds,
|
||||
unitId,
|
||||
className,
|
||||
size = "md",
|
||||
}: TagListProps) {
|
||||
const localizedName = useLocalizedName();
|
||||
const { recordTagsList, isLoadingRecordTagsList } = useRecordTags({ unitId });
|
||||
const tags = recordTagsList?.items ?? [];
|
||||
const selectedTags = tags.filter((tag) => tagIds.includes(tag.id));
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "text-[10px] px-1.5 py-0",
|
||||
md: "text-xs px-2 py-0.5",
|
||||
lg: "text-sm px-2.5 py-1",
|
||||
};
|
||||
|
||||
if (isLoadingRecordTagsList) {
|
||||
return <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />;
|
||||
}
|
||||
|
||||
if (selectedTags.length === 0) {
|
||||
return <span className="text-xs text-muted-foreground">—</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap gap-1.5", className)}>
|
||||
{selectedTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant="outline"
|
||||
className={cn("font-medium gap-1.5", sizeClasses[size])}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" />
|
||||
{localizedName(tag.name)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Create Tag Dialog Integration ─────────────────────
|
||||
|
||||
export interface TagSelectorWithCreateProps extends TagSelectorProps {
|
||||
onCreateTag?: (name: string) => void;
|
||||
isCreatingTag?: boolean;
|
||||
}
|
||||
|
||||
export function RecordTagSelectorWithCreate({
|
||||
onCreateTag,
|
||||
isCreatingTag,
|
||||
...props
|
||||
}: TagSelectorWithCreateProps) {
|
||||
const { t } = useTranslation();
|
||||
const [newTagName, setNewTagName] = React.useState("");
|
||||
|
||||
const handleCreate = React.useCallback(() => {
|
||||
if (newTagName.trim() && onCreateTag) {
|
||||
onCreateTag(newTagName.trim());
|
||||
setNewTagName("");
|
||||
}
|
||||
}, [newTagName, onCreateTag]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<RecordTagSelector {...props} />
|
||||
|
||||
{onCreateTag && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Tag className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
placeholder={t("tags.createPlaceholder") || "Create new tag..."}
|
||||
className="w-full h-8 pl-8 pr-3 text-xs rounded-md border border-input bg-background
|
||||
focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={handleCreate}
|
||||
disabled={!newTagName.trim() || isCreatingTag}
|
||||
>
|
||||
{isCreatingTag ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
t("common.create") || "Create"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,375 +1,375 @@
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Stamp, X, Plus } from "lucide-react";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useSeal } from "@/user-management/hooks/useSeal";
|
||||
import {
|
||||
CreateSealPayload,
|
||||
sealService,
|
||||
} from "@/user-management/services/api/sealService";
|
||||
import { toast } from "sonner";
|
||||
import { FilePreview } from "./ContentManagement";
|
||||
import { presignedAxios } from "@/shared/services/presignedAxios";
|
||||
import { t } from "i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface Props {
|
||||
unitId: string;
|
||||
}
|
||||
|
||||
const SealCard = ({ unitId }: Props) => {
|
||||
const {
|
||||
createSeal,
|
||||
isCreatingSeal,
|
||||
isLoadingSeals,
|
||||
deleteSeal,
|
||||
getSealsByUnitId,
|
||||
} = useSeal();
|
||||
|
||||
const [file, setFile] = useState<File>();
|
||||
const [previews, setPreviews] = useState<FilePreview[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const [sealName, setSealName] = useState("");
|
||||
const [sealNameError, setSealNameError] = useState("");
|
||||
|
||||
const MAX_SIZE = 1 * 1024 * 1024;
|
||||
|
||||
const { data: seals } = getSealsByUnitId(unitId);
|
||||
|
||||
// -------------------------------
|
||||
// Fetch existing seals
|
||||
// -------------------------------
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchPresigned = async () => {
|
||||
if (!seals?.items) return;
|
||||
|
||||
const filtered = seals.items.filter(
|
||||
(s) => s.uploadedSuccessfully && s.isCurrent
|
||||
);
|
||||
|
||||
const resolved = await Promise.all(
|
||||
filtered.map(async (seal): Promise<FilePreview | null> => {
|
||||
try {
|
||||
const sealDetail = await sealService.getSeal(seal.id);
|
||||
const presigned = sealDetail.data.presigned;
|
||||
|
||||
if (!presigned) return null;
|
||||
|
||||
return {
|
||||
type: "seal",
|
||||
file: new File([], seal.fileInfo.fileName),
|
||||
url: presigned,
|
||||
uploadedAt: new Date(seal.createdAt),
|
||||
id: seal.id,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const filteredPreviews: FilePreview[] = resolved.filter(
|
||||
(p): p is FilePreview => p !== null
|
||||
);
|
||||
|
||||
if (isMounted) {
|
||||
setPreviews(filteredPreviews);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPresigned();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
|
||||
previews.forEach((p) => {
|
||||
if (!p.id && p.url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(p.url);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [seals?.items]);
|
||||
|
||||
// -------------------------------
|
||||
// File validation + preview
|
||||
// -------------------------------
|
||||
const handleFileUpload = (selectedFile: File) => {
|
||||
if (selectedFile.type !== "image/png") {
|
||||
toast.error(t("contentManagement.onlyPngAllowed"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedFile.size > MAX_SIZE) {
|
||||
toast.error("Image size should be less than 1 MB");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempUrl = URL.createObjectURL(selectedFile);
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
if (img.width !== img.height) {
|
||||
toast.error(t("contentManagement.onlySquareAllowed"));
|
||||
URL.revokeObjectURL(tempUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
setSealName("");
|
||||
setSealNameError("");
|
||||
|
||||
setPreviews([
|
||||
{
|
||||
type: "seal",
|
||||
url: tempUrl,
|
||||
file: selectedFile,
|
||||
uploadedAt: new Date(),
|
||||
id: null,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
toast.error(t("contentManagement.invalidImage"));
|
||||
URL.revokeObjectURL(tempUrl);
|
||||
};
|
||||
|
||||
img.src = tempUrl;
|
||||
};
|
||||
|
||||
// -------------------------------
|
||||
// Upload flow (CREATE + PUT)
|
||||
// -------------------------------
|
||||
const uploadSeal = async () => {
|
||||
if (!file) {
|
||||
toast.error(t("contentManagement.noFileSelected"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sealName.trim()) {
|
||||
setSealNameError(t("contentManagement.sealNameMsg"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Create seal (get presigned URL)
|
||||
const payload: CreateSealPayload = {
|
||||
fileInfo: {
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
originalname: file.name,
|
||||
},
|
||||
name: {
|
||||
am: sealName,
|
||||
en: sealName,
|
||||
},
|
||||
unitId,
|
||||
};
|
||||
|
||||
const data = await createSeal(payload);
|
||||
|
||||
// Step 2: Upload file
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
await presignedAxios.put(data.presigned, file, {
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
});
|
||||
|
||||
// await updateSealStatus({
|
||||
// id: data.id,
|
||||
// updateSealStatusPayload: {
|
||||
// id: data.id,
|
||||
// parentId: data.id,
|
||||
// uploadedSuccessfully: true,
|
||||
// },
|
||||
// });
|
||||
|
||||
setIsUploading(false);
|
||||
setUploadProgress(100);
|
||||
|
||||
// Reset UI
|
||||
setFile(undefined);
|
||||
setSealName("");
|
||||
setSealNameError("");
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["seal"] });
|
||||
|
||||
toast.success(t("contentManagement.sealSuccessMsg"));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t("contentManagement.sealErrorMsg"));
|
||||
|
||||
setIsUploading(false);
|
||||
setUploadProgress(0);
|
||||
setFile(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------
|
||||
// Delete seal
|
||||
// -------------------------------
|
||||
const handleRemove = async (id: string | null) => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await deleteSeal(id);
|
||||
setPreviews((prev) => prev.filter((p) => p.id !== id));
|
||||
toast.success(t("contentManagement.sealRemove"));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t("contentManagement.sealRemove"));
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
|
||||
const isProcessing = isCreatingSeal || isUploading;
|
||||
|
||||
return (
|
||||
<Card className="dark:border-gray-700 dark:bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center dark:text-white">
|
||||
<Stamp className="h-5 w-5 mr-2" />
|
||||
{t("contentManagement.seal")}
|
||||
</CardTitle>
|
||||
<CardDescription className="dark:text-gray-400">
|
||||
{t("contentManagement.uploadSealMsg")} (PNG, max 1MB)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label htmlFor="seal" className="dark:text-gray-200">{t("contentManagement.sealImage")}</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("contentManagement.addSeal")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".png,image/png"
|
||||
className="hidden"
|
||||
onChange={(e) =>
|
||||
e.target.files?.[0] && handleFileUpload(e.target.files[0])
|
||||
}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
|
||||
{/* PREVIEWS */}
|
||||
{previews.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{previews.map((preview) => (
|
||||
<div
|
||||
key={preview?.id || preview?.url}
|
||||
className="border rounded-lg p-3 bg-gray-50 dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-muted-foreground dark:text-gray-400">
|
||||
{formatDate(preview?.uploadedAt)}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(preview?.id || null)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/30"
|
||||
disabled={isLoadingSeals}
|
||||
>
|
||||
<X className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={preview.url}
|
||||
alt="Seal"
|
||||
className="max-h-32 mx-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-4 border border-dashed rounded-lg dark:border-gray-600 dark:bg-gray-700/30">
|
||||
<p className="text-muted-foreground dark:text-gray-400">
|
||||
{t("contentManagement.noSealMsg")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground dark:text-gray-500 mt-1">
|
||||
{t("contentManagement.addSealInstruction")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NAME INPUT */}
|
||||
{file && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sealName" className="dark:text-gray-200">{t("contentManagement.sealName")}</Label>
|
||||
<Input
|
||||
value={sealName}
|
||||
onChange={(e) => {
|
||||
setSealName(e.target.value);
|
||||
setSealNameError("");
|
||||
}}
|
||||
placeholder="Enter seal name"
|
||||
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
|
||||
{sealNameError && (
|
||||
<p className="text-sm text-red-500">{sealNameError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UPLOAD BUTTON (MAIN UX CONTROL) */}
|
||||
{file && (
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary/90"
|
||||
onClick={uploadSeal}
|
||||
disabled={isProcessing || !file}
|
||||
>
|
||||
{isCreatingSeal && "Creating seal..."}
|
||||
{isUploading && `Uploading ${uploadProgress}%`}
|
||||
{!isProcessing && t("contentManagement.saveSeal")}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Stamp, X, Plus } from "lucide-react";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Label } from "@/shared/common/ui/label";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useSeal } from "@/user-management/hooks/useSeal";
|
||||
import {
|
||||
CreateSealPayload,
|
||||
sealService,
|
||||
} from "@/user-management/services/api/sealService";
|
||||
import { toast } from "sonner";
|
||||
import { FilePreview } from "./ContentManagement";
|
||||
import { presignedAxios } from "@/shared/services/presignedAxios";
|
||||
import { t } from "i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface Props {
|
||||
unitId: string;
|
||||
}
|
||||
|
||||
const SealCard = ({ unitId }: Props) => {
|
||||
const {
|
||||
createSeal,
|
||||
isCreatingSeal,
|
||||
isLoadingSeals,
|
||||
deleteSeal,
|
||||
getSealsByUnitId,
|
||||
} = useSeal();
|
||||
|
||||
const [file, setFile] = useState<File>();
|
||||
const [previews, setPreviews] = useState<FilePreview[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const [sealName, setSealName] = useState("");
|
||||
const [sealNameError, setSealNameError] = useState("");
|
||||
|
||||
const MAX_SIZE = 1 * 1024 * 1024;
|
||||
|
||||
const { data: seals } = getSealsByUnitId(unitId);
|
||||
|
||||
// -------------------------------
|
||||
// Fetch existing seals
|
||||
// -------------------------------
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchPresigned = async () => {
|
||||
if (!seals?.items) return;
|
||||
|
||||
const filtered = seals.items.filter(
|
||||
(s) => s.uploadedSuccessfully && s.isCurrent
|
||||
);
|
||||
|
||||
const resolved = await Promise.all(
|
||||
filtered.map(async (seal): Promise<FilePreview | null> => {
|
||||
try {
|
||||
const sealDetail = await sealService.getSeal(seal.id);
|
||||
const presigned = sealDetail.data.presigned;
|
||||
|
||||
if (!presigned) return null;
|
||||
|
||||
return {
|
||||
type: "seal",
|
||||
file: new File([], seal.fileInfo.fileName),
|
||||
url: presigned,
|
||||
uploadedAt: new Date(seal.createdAt),
|
||||
id: seal.id,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const filteredPreviews: FilePreview[] = resolved.filter(
|
||||
(p): p is FilePreview => p !== null
|
||||
);
|
||||
|
||||
if (isMounted) {
|
||||
setPreviews(filteredPreviews);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPresigned();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
|
||||
previews.forEach((p) => {
|
||||
if (!p.id && p.url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(p.url);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [seals?.items]);
|
||||
|
||||
// -------------------------------
|
||||
// File validation + preview
|
||||
// -------------------------------
|
||||
const handleFileUpload = (selectedFile: File) => {
|
||||
if (selectedFile.type !== "image/png") {
|
||||
toast.error(t("contentManagement.onlyPngAllowed"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedFile.size > MAX_SIZE) {
|
||||
toast.error("Image size should be less than 1 MB");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempUrl = URL.createObjectURL(selectedFile);
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
if (img.width !== img.height) {
|
||||
toast.error(t("contentManagement.onlySquareAllowed"));
|
||||
URL.revokeObjectURL(tempUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
setSealName("");
|
||||
setSealNameError("");
|
||||
|
||||
setPreviews([
|
||||
{
|
||||
type: "seal",
|
||||
url: tempUrl,
|
||||
file: selectedFile,
|
||||
uploadedAt: new Date(),
|
||||
id: null,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
toast.error(t("contentManagement.invalidImage"));
|
||||
URL.revokeObjectURL(tempUrl);
|
||||
};
|
||||
|
||||
img.src = tempUrl;
|
||||
};
|
||||
|
||||
// -------------------------------
|
||||
// Upload flow (CREATE + PUT)
|
||||
// -------------------------------
|
||||
const uploadSeal = async () => {
|
||||
if (!file) {
|
||||
toast.error(t("contentManagement.noFileSelected"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sealName.trim()) {
|
||||
setSealNameError(t("contentManagement.sealNameMsg"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Create seal (get presigned URL)
|
||||
const payload: CreateSealPayload = {
|
||||
fileInfo: {
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
originalname: file.name,
|
||||
},
|
||||
name: {
|
||||
am: sealName,
|
||||
en: sealName,
|
||||
},
|
||||
unitId,
|
||||
};
|
||||
|
||||
const data = await createSeal(payload);
|
||||
|
||||
// Step 2: Upload file
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
await presignedAxios.put(data.presigned, file, {
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
});
|
||||
|
||||
// await updateSealStatus({
|
||||
// id: data.id,
|
||||
// updateSealStatusPayload: {
|
||||
// id: data.id,
|
||||
// parentId: data.id,
|
||||
// uploadedSuccessfully: true,
|
||||
// },
|
||||
// });
|
||||
|
||||
setIsUploading(false);
|
||||
setUploadProgress(100);
|
||||
|
||||
// Reset UI
|
||||
setFile(undefined);
|
||||
setSealName("");
|
||||
setSealNameError("");
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["seal"] });
|
||||
|
||||
toast.success(t("contentManagement.sealSuccessMsg"));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t("contentManagement.sealErrorMsg"));
|
||||
|
||||
setIsUploading(false);
|
||||
setUploadProgress(0);
|
||||
setFile(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------
|
||||
// Delete seal
|
||||
// -------------------------------
|
||||
const handleRemove = async (id: string | null) => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await deleteSeal(id);
|
||||
setPreviews((prev) => prev.filter((p) => p.id !== id));
|
||||
toast.success(t("contentManagement.sealRemove"));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(t("contentManagement.sealRemove"));
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(date);
|
||||
|
||||
const isProcessing = isCreatingSeal || isUploading;
|
||||
|
||||
return (
|
||||
<Card className="dark:border-gray-700 dark:bg-gray-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center dark:text-white">
|
||||
<Stamp className="h-5 w-5 mr-2" />
|
||||
{t("contentManagement.seal")}
|
||||
</CardTitle>
|
||||
<CardDescription className="dark:text-gray-400">
|
||||
{t("contentManagement.uploadSealMsg")} (PNG, max 1MB)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label htmlFor="seal" className="dark:text-gray-200">{t("contentManagement.sealImage")}</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("contentManagement.addSeal")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".png,image/png"
|
||||
className="hidden"
|
||||
onChange={(e) =>
|
||||
e.target.files?.[0] && handleFileUpload(e.target.files[0])
|
||||
}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
|
||||
{/* PREVIEWS */}
|
||||
{previews.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{previews.map((preview) => (
|
||||
<div
|
||||
key={preview?.id || preview?.url}
|
||||
className="border rounded-lg p-3 bg-gray-50 dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-muted-foreground dark:text-gray-400">
|
||||
{formatDate(preview?.uploadedAt)}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemove(preview?.id || null)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/30"
|
||||
disabled={isLoadingSeals}
|
||||
>
|
||||
<X className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={preview.url}
|
||||
alt="Seal"
|
||||
className="max-h-32 mx-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-4 border border-dashed rounded-lg dark:border-gray-600 dark:bg-gray-700/30">
|
||||
<p className="text-muted-foreground dark:text-gray-400">
|
||||
{t("contentManagement.noSealMsg")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground dark:text-gray-500 mt-1">
|
||||
{t("contentManagement.addSealInstruction")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NAME INPUT */}
|
||||
{file && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sealName" className="dark:text-gray-200">{t("contentManagement.sealName")}</Label>
|
||||
<Input
|
||||
value={sealName}
|
||||
onChange={(e) => {
|
||||
setSealName(e.target.value);
|
||||
setSealNameError("");
|
||||
}}
|
||||
placeholder="Enter seal name"
|
||||
className="dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
|
||||
{sealNameError && (
|
||||
<p className="text-sm text-red-500">{sealNameError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UPLOAD BUTTON (MAIN UX CONTROL) */}
|
||||
{file && (
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary/90"
|
||||
onClick={uploadSeal}
|
||||
disabled={isProcessing || !file}
|
||||
>
|
||||
{isCreatingSeal && "Creating seal..."}
|
||||
{isUploading && `Uploading ${uploadProgress}%`}
|
||||
{!isProcessing && t("contentManagement.saveSeal")}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SealCard;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,126 +1,126 @@
|
||||
import { useState } from "react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
|
||||
import { RecordTag } from "@/user-management/dto/recordTags/recordTags.type";
|
||||
import { RecordTagForm } from "./RecordTagForm";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
interface Props {
|
||||
row: RecordTag;
|
||||
unitId: string;
|
||||
}
|
||||
|
||||
export default function RecordTagActionsCell({ row, unitId }: Props) {
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false); // confirmation dialog
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const { deleteRecordTag, refetchRecordTagsList } = useRecordTags({ unitId });
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
// --- Handle Delete ---
|
||||
const confirmDelete = () => {
|
||||
setIsDeleteLoading(true);
|
||||
deleteRecordTag(row.id, {
|
||||
onSuccess: () => {
|
||||
refetchRecordTagsList();
|
||||
setIsDeleteOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsDeleteLoading(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Edit Button */}
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditOpen(true)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Delete Button (opens confirm) */}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setIsDeleteOpen(true)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Record Tag</DialogTitle>
|
||||
</DialogHeader>
|
||||
<RecordTagForm
|
||||
unitId={unitId}
|
||||
mode="edit"
|
||||
id={row.id}
|
||||
defaultValues={{
|
||||
nameAm: row.name?.am ?? "",
|
||||
nameEn: row.name?.en ?? "",
|
||||
key: row.key,
|
||||
unitId: row.unitId,
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setIsEditOpen(false);
|
||||
refetchRecordTagsList();
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure you want to delete this record tag?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. The record tag <b>{row.name?.en}</b>{" "}
|
||||
will be permanently removed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleteLoading}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
disabled={isDeleteLoading}
|
||||
className="bg-red-600 hover:bg-red-700">
|
||||
{isDeleteLoading ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
|
||||
import { RecordTag } from "@/user-management/dto/recordTags/recordTags.type";
|
||||
import { RecordTagForm } from "./RecordTagForm";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
interface Props {
|
||||
row: RecordTag;
|
||||
unitId: string;
|
||||
}
|
||||
|
||||
export default function RecordTagActionsCell({ row, unitId }: Props) {
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false); // confirmation dialog
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const { deleteRecordTag, refetchRecordTagsList } = useRecordTags({ unitId });
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
// --- Handle Delete ---
|
||||
const confirmDelete = () => {
|
||||
setIsDeleteLoading(true);
|
||||
deleteRecordTag(row.id, {
|
||||
onSuccess: () => {
|
||||
refetchRecordTagsList();
|
||||
setIsDeleteOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
handleError(error);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsDeleteLoading(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Edit Button */}
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditOpen(true)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Delete Button (opens confirm) */}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setIsDeleteOpen(true)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Record Tag</DialogTitle>
|
||||
</DialogHeader>
|
||||
<RecordTagForm
|
||||
unitId={unitId}
|
||||
mode="edit"
|
||||
id={row.id}
|
||||
defaultValues={{
|
||||
nameAm: row.name?.am ?? "",
|
||||
nameEn: row.name?.en ?? "",
|
||||
key: row.key,
|
||||
unitId: row.unitId,
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setIsEditOpen(false);
|
||||
refetchRecordTagsList();
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure you want to delete this record tag?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. The record tag <b>{row.name?.en}</b>{" "}
|
||||
will be permanently removed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleteLoading}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
disabled={isDeleteLoading}
|
||||
className="bg-red-600 hover:bg-red-700">
|
||||
{isDeleteLoading ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,153 +1,153 @@
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormControl,
|
||||
} from "@/shared/common/ui/form";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
|
||||
import {
|
||||
CreateRecordTagPayload,
|
||||
UpdateRecordTagPayload,
|
||||
} from "@/user-management/dto/recordTags/recordTags.type";
|
||||
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
|
||||
|
||||
const recordTagSchema = z.object({
|
||||
nameAm: z.string().min(1, "Amharic name is required"),
|
||||
nameEn: z.string().min(1, "English name is required"),
|
||||
key: z.string().min(1, "Key is required"),
|
||||
unitId: z.string().min(1, "Unit is required"),
|
||||
});
|
||||
|
||||
export type RecordTagFormValues = z.infer<typeof recordTagSchema>;
|
||||
|
||||
interface RecordTagFormProps {
|
||||
defaultValues?: Partial<RecordTagFormValues>;
|
||||
unitId: string;
|
||||
mode: "create" | "edit";
|
||||
id?: string; // required if edit
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function RecordTagForm({
|
||||
defaultValues,
|
||||
unitId,
|
||||
mode,
|
||||
id,
|
||||
onSuccess,
|
||||
}: RecordTagFormProps) {
|
||||
const form = useForm<RecordTagFormValues>({
|
||||
resolver: zodResolver(recordTagSchema),
|
||||
defaultValues: {
|
||||
nameAm: defaultValues?.nameAm ?? "",
|
||||
nameEn: defaultValues?.nameEn ?? "",
|
||||
key: defaultValues?.key ?? "",
|
||||
unitId: defaultValues?.unitId ?? unitId,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
createRecordTag,
|
||||
updateRecordTag,
|
||||
isCreatingRecordTag,
|
||||
isUpdatingRecordTag,
|
||||
} = useRecordTags({ unitId });
|
||||
|
||||
const handleSubmit = (values: RecordTagFormValues) => {
|
||||
if (mode === "create") {
|
||||
const payload: CreateRecordTagPayload = {
|
||||
name: { am: values.nameAm, en: values.nameEn },
|
||||
key: values.key,
|
||||
unitId: values.unitId,
|
||||
};
|
||||
createRecordTag(payload, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
} else if (mode === "edit" && id) {
|
||||
const payload: UpdateRecordTagPayload = {
|
||||
name: { am: values.nameAm, en: values.nameEn },
|
||||
key: values.key,
|
||||
unitId: values.unitId,
|
||||
};
|
||||
updateRecordTag(
|
||||
{ id, payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess?.();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameAm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Amharic Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter Amharic name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameEn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>English Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter English name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Unique key" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isCreatingRecordTag || isUpdatingRecordTag}
|
||||
>
|
||||
{mode === "create"
|
||||
? isCreatingRecordTag
|
||||
? "Creating..."
|
||||
: "Create"
|
||||
: isUpdatingRecordTag
|
||||
? "Updating..."
|
||||
: "Update"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormControl,
|
||||
} from "@/shared/common/ui/form";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
|
||||
import {
|
||||
CreateRecordTagPayload,
|
||||
UpdateRecordTagPayload,
|
||||
} from "@/user-management/dto/recordTags/recordTags.type";
|
||||
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
|
||||
|
||||
const recordTagSchema = z.object({
|
||||
nameAm: z.string().min(1, "Amharic name is required"),
|
||||
nameEn: z.string().min(1, "English name is required"),
|
||||
key: z.string().min(1, "Key is required"),
|
||||
unitId: z.string().min(1, "Unit is required"),
|
||||
});
|
||||
|
||||
export type RecordTagFormValues = z.infer<typeof recordTagSchema>;
|
||||
|
||||
interface RecordTagFormProps {
|
||||
defaultValues?: Partial<RecordTagFormValues>;
|
||||
unitId: string;
|
||||
mode: "create" | "edit";
|
||||
id?: string; // required if edit
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function RecordTagForm({
|
||||
defaultValues,
|
||||
unitId,
|
||||
mode,
|
||||
id,
|
||||
onSuccess,
|
||||
}: RecordTagFormProps) {
|
||||
const form = useForm<RecordTagFormValues>({
|
||||
resolver: zodResolver(recordTagSchema),
|
||||
defaultValues: {
|
||||
nameAm: defaultValues?.nameAm ?? "",
|
||||
nameEn: defaultValues?.nameEn ?? "",
|
||||
key: defaultValues?.key ?? "",
|
||||
unitId: defaultValues?.unitId ?? unitId,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
createRecordTag,
|
||||
updateRecordTag,
|
||||
isCreatingRecordTag,
|
||||
isUpdatingRecordTag,
|
||||
} = useRecordTags({ unitId });
|
||||
|
||||
const handleSubmit = (values: RecordTagFormValues) => {
|
||||
if (mode === "create") {
|
||||
const payload: CreateRecordTagPayload = {
|
||||
name: { am: values.nameAm, en: values.nameEn },
|
||||
key: values.key,
|
||||
unitId: values.unitId,
|
||||
};
|
||||
createRecordTag(payload, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
} else if (mode === "edit" && id) {
|
||||
const payload: UpdateRecordTagPayload = {
|
||||
name: { am: values.nameAm, en: values.nameEn },
|
||||
key: values.key,
|
||||
unitId: values.unitId,
|
||||
};
|
||||
updateRecordTag(
|
||||
{ id, payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess?.();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameAm"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Amharic Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter Amharic name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nameEn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>English Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter English name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Unique key" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isCreatingRecordTag || isUpdatingRecordTag}
|
||||
>
|
||||
{mode === "create"
|
||||
? isCreatingRecordTag
|
||||
? "Creating..."
|
||||
: "Create"
|
||||
: isUpdatingRecordTag
|
||||
? "Updating..."
|
||||
: "Update"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,89 +1,89 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
|
||||
import { RecordTagColumnDefn } from "./RecordTagsColumn";
|
||||
import { RecordTagForm } from "./RecordTagForm";
|
||||
|
||||
export default function RecordTagsManagement({ unitId }: { unitId: string }) {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// --- Record Tags Query ---
|
||||
const { recordTagsList, isLoadingRecordTagsList, refetchRecordTagsList } =
|
||||
useRecordTags({
|
||||
unitId: unitId ?? "",
|
||||
});
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPageIndex(newPage);
|
||||
};
|
||||
|
||||
if (isLoadingRecordTagsList) {
|
||||
return <div>{t("loading")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="col-span-2 shadow-none border-none bg-transparent px-0">
|
||||
<CardHeader className="flex flex-row justify-between items-center px-0">
|
||||
<CardTitle className="text-xl font-semibold">
|
||||
{t("recordTag.title")}
|
||||
</CardTitle>
|
||||
|
||||
{/* Create Button (opens modal) */}
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>{t("recordTag.createNew")}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("recordTag.createTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<RecordTagForm
|
||||
unitId={unitId}
|
||||
mode="create"
|
||||
onSuccess={() => {
|
||||
setIsOpen(false);
|
||||
refetchRecordTagsList();
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
|
||||
{/* Record Tags Table */}
|
||||
<CardContent className="px-0">
|
||||
<AdvancedTable
|
||||
columns={RecordTagColumnDefn(unitId)}
|
||||
data={recordTagsList?.items || []}
|
||||
tableName={t("recordTag.tableName")}
|
||||
toolBarPosition="right"
|
||||
itemCount={recordTagsList?.count || 0}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={handlePageChange}
|
||||
nextFunction={() => handlePageChange(pageIndex + 1)}
|
||||
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/common/ui/dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRecordTags } from "@/user-management/hooks/useRecordTags";
|
||||
import { RecordTagColumnDefn } from "./RecordTagsColumn";
|
||||
import { RecordTagForm } from "./RecordTagForm";
|
||||
|
||||
export default function RecordTagsManagement({ unitId }: { unitId: string }) {
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// --- Record Tags Query ---
|
||||
const { recordTagsList, isLoadingRecordTagsList, refetchRecordTagsList } =
|
||||
useRecordTags({
|
||||
unitId: unitId ?? "",
|
||||
});
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPageIndex(newPage);
|
||||
};
|
||||
|
||||
if (isLoadingRecordTagsList) {
|
||||
return <div>{t("loading")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Card className="col-span-2 shadow-none border-none bg-transparent px-0">
|
||||
<CardHeader className="flex flex-row justify-between items-center px-0">
|
||||
<CardTitle className="text-xl font-semibold">
|
||||
{t("recordTag.title")}
|
||||
</CardTitle>
|
||||
|
||||
{/* Create Button (opens modal) */}
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>{t("recordTag.createNew")}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("recordTag.createTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<RecordTagForm
|
||||
unitId={unitId}
|
||||
mode="create"
|
||||
onSuccess={() => {
|
||||
setIsOpen(false);
|
||||
refetchRecordTagsList();
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
|
||||
{/* Record Tags Table */}
|
||||
<CardContent className="px-0">
|
||||
<AdvancedTable
|
||||
columns={RecordTagColumnDefn(unitId)}
|
||||
data={recordTagsList?.items || []}
|
||||
tableName={t("recordTag.tableName")}
|
||||
toolBarPosition="right"
|
||||
itemCount={recordTagsList?.count || 0}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={handlePageChange}
|
||||
nextFunction={() => handlePageChange(pageIndex + 1)}
|
||||
prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { RecordTag } from "@/user-management/dto/recordTags/recordTags.type";
|
||||
import { format } from "date-fns";
|
||||
import { t } from "i18next";
|
||||
import RecordTagActionsCell from "./RecordTagActionsCell";
|
||||
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
|
||||
export const RecordTagColumnDefn = (unitId: string): ColumnDef<RecordTag>[] => {
|
||||
return [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: () => t("recordTag.name"),
|
||||
cell: ({ row }) => {
|
||||
const localizedName = useLocalizedName();
|
||||
const name = row.original?.name;
|
||||
return <span>{localizedName(name)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "key",
|
||||
header: () => t("recordTag.key"),
|
||||
cell: ({ row }) => {
|
||||
const key = row.original?.key;
|
||||
return <span>{key || "--"}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: () => t("recordTag.CreatedAt"),
|
||||
cell: ({ row }) => {
|
||||
const date = row.original?.createdAt;
|
||||
return (
|
||||
<span>
|
||||
{date ? format(new Date(date), "MMM d, yyyy HH:mm") : "--"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => t("recordTag.Actions"),
|
||||
cell: ({ row }) => (
|
||||
<RecordTagActionsCell row={row.original} unitId={unitId} />
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { RecordTag } from "@/user-management/dto/recordTags/recordTags.type";
|
||||
import { format } from "date-fns";
|
||||
import { t } from "i18next";
|
||||
import RecordTagActionsCell from "./RecordTagActionsCell";
|
||||
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
|
||||
export const RecordTagColumnDefn = (unitId: string): ColumnDef<RecordTag>[] => {
|
||||
return [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: () => t("recordTag.name"),
|
||||
cell: ({ row }) => {
|
||||
const localizedName = useLocalizedName();
|
||||
const name = row.original?.name;
|
||||
return <span>{localizedName(name)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "key",
|
||||
header: () => t("recordTag.key"),
|
||||
cell: ({ row }) => {
|
||||
const key = row.original?.key;
|
||||
return <span>{key || "--"}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: () => t("recordTag.CreatedAt"),
|
||||
cell: ({ row }) => {
|
||||
const date = row.original?.createdAt;
|
||||
return (
|
||||
<span>
|
||||
{date ? format(new Date(date), "MMM d, yyyy HH:mm") : "--"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => t("recordTag.Actions"),
|
||||
cell: ({ row }) => (
|
||||
<RecordTagActionsCell row={row.original} unitId={unitId} />
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export function useDebounceCallback<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number
|
||||
): T {
|
||||
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
return ((...args: Parameters<T>) => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
const newTimeoutId = setTimeout(() => {
|
||||
callback(...args);
|
||||
}, delay);
|
||||
|
||||
setTimeoutId(newTimeoutId);
|
||||
}) as T;
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export function useDebounceCallback<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number
|
||||
): T {
|
||||
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
return ((...args: Parameters<T>) => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
const newTimeoutId = setTimeout(() => {
|
||||
callback(...args);
|
||||
}, delay);
|
||||
|
||||
setTimeoutId(newTimeoutId);
|
||||
}) as T;
|
||||
}
|
||||
Reference in New Issue
Block a user