Compare commits

..

No commits in common. "main" and "chore/upgrade-next-react2" have entirely different histories.

6678 changed files with 19108 additions and 786211 deletions

View File

@ -1,44 +0,0 @@
kind: pipeline
type: ssh
name: kontenhumas-fe-build-deploy
server:
host:
from_secret: ssh_host
user:
from_secret: ssh_user
ssh_key:
from_secret: ssh_key
steps:
- name: prepare repo
when:
branch:
- dev-1
- main
commands:
- rm -rf /opt/build/kontenhumas-fe
- mkdir -p /opt/build
- cd /opt/build
- git clone http://38.47.180.165:3000/kontenhumas/kontenhumas-fe.git
- name: build image
when:
branch:
- dev-1
- main
commands:
- docker login 38.47.180.165:3000 -u administrator -p HarborDockerImageRep0
- cd /opt/build/kontenhumas-fe
- docker build -t 38.47.180.165:3000/kontenhumas/kontenhumas-fe:$DRONE_BRANCH .
- docker push 38.47.180.165:3000/kontenhumas/kontenhumas-fe:$DRONE_BRANCH
- name: deploy
when:
branch:
- main
commands:
- docker pull 38.47.180.165:3000/kontenhumas/kontenhumas-fe:$DRONE_BRANCH
- docker stop new-netidhub || true
- docker rm new-netidhub || true
- docker run -dt -p 4242:3000 --restart always --name new-netidhub 38.47.180.165:3000/kontenhumas/kontenhumas-fe:$DRONE_BRANCH

3
.env
View File

@ -1,2 +1 @@
NETIDHUB_CLIENT_KEY=b1ce6602-07ad-46c2-85eb-0cd6decfefa3
NEXT_PUBLIC_API_URL=https://kontenhumas.com/api
NETIDHUB_CLIENT_KEY=b1ce6602-07ad-46c2-85eb-0cd6decfefa3

1
.gitignore vendored
View File

@ -36,4 +36,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.env.local

View File

@ -10,16 +10,16 @@ build-dev:
image: docker:24.0.5
services:
- name: docker:24.0.5-dind
command: ["--tls=false", "--insecure-registry=38.47.185.86:8900"]
command: ["--tls=false", "--insecure-registry=103.82.242.92:8900"]
variables:
DOCKER_HOST: "tcp://docker:2375"
DOCKER_TLS_CERTDIR: ""
DOCKER_TLS_CERTDIR: ""
script:
- docker logout
- echo "$DEPLOY_TOKEN" | docker login -u "$DEPLOY_USERNAME" --password-stdin http://38.47.185.86:8900
- echo "$DEPLOY_TOKEN" | docker login -u "$DEPLOY_USERNAME" --password-stdin http://103.82.242.92:8900
- docker build -t new-netidhub-dev .
- docker tag new-netidhub-dev 38.47.185.86:8900/medols/new-netidhub:dev
- docker push 38.47.185.86:8900/medols/new-netidhub:dev
- docker tag new-netidhub-dev 103.82.242.92:8900/medols/new-netidhub:dev
- docker push 103.82.242.92:8900/medols/new-netidhub:dev
auto-deploy:
stage: deploy

View File

@ -4,16 +4,6 @@ FROM node:23.5.0-alpine
# Mengatur port
ENV PORT 3000
# Build arguments untuk environment variables (build-time)
# Bisa di-override saat docker build dengan --build-arg
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_SITE_URL
# Set sebagai environment variables untuk build
# Next.js membaca NEXT_PUBLIC_* variables saat BUILD TIME
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
# Install pnpm secara global
RUN npm install -g pnpm
@ -26,19 +16,17 @@ COPY package.json ./
# Menyalin direktori ckeditor5 jika diperlukan
COPY vendor/ckeditor5 ./vendor/ckeditor5
# Menyalin env
COPY .env .env
# Install dependencies
RUN pnpm install
# RUN pnpm install --frozen-lockfile
# Menyalin source code aplikasi (termasuk .env jika ada)
# PENTING: Next.js akan membaca file .env otomatis jika ada
# Tapi jika ARG di-set, ARG akan override nilai dari .env
# Menyalin source code aplikasi
COPY . .
# Build aplikasi
# Next.js membaca NEXT_PUBLIC_* dari:
# 1. Environment variables (ENV) - prioritas tertinggi
# 2. File .env - jika ENV tidak di-set
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm next build
# Expose port untuk server

View File

@ -29,11 +29,11 @@ import {
checkRolePlacementsAvailability,
getListCompetencies,
getListExperiences,
saveUserInternal,
saveUserRolePlacements,
} from "@/service/management-user/management-user";
import { error, loading } from "@/config/swal";
import { Eye, EyeOff } from "lucide-react";
import { saveUserInternal } from "@/service/service/management-user/management-user";
const FormSchema = z.object({
name: z.string({

View File

@ -29,12 +29,12 @@ import {
getListCompetencies,
getListExperiences,
getUserById,
saveUserInternal,
saveUserRolePlacements,
} from "@/service/management-user/management-user";
import { loading } from "@/config/swal";
import { Eye, EyeOff } from "lucide-react";
import { useParams } from "next/navigation";
import { saveUserInternal } from "@/service/service/management-user/management-user";
const FormSchema = z.object({
name: z.string({

View File

@ -71,7 +71,7 @@ const TableCategories = () => {
{
accessorKey: "statusId",
header: "Status",
cell: ({ row }) => (row.original.isActive ? "Active" : "Draft"),
cell: ({ row }) => (row.original.isPublish ? "Publish" : "Draft"),
},
{
id: "actions",
@ -187,7 +187,7 @@ const TableCategories = () => {
return (
<>
<div className="w-full overflow-x-auto bg-white dark:bg-default-50 rounded-lg border border-gray-200 shadow-sm">
<div className="w-full overflow-x-auto bg-white rounded-lg border border-gray-200 shadow-sm">
<div className="flex justify-between items-center px-4 py-2">
<h2 className="text-lg font-semibold">Daftar Kategori</h2>

View File

@ -127,12 +127,12 @@ const ReactTableImagePage = () => {
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-default-50">
<div className="min-h-screen bg-gray-50">
{/* <SiteBreadcrumb /> */}
<div className="p-6">
<div className="max-w-7xl mx-auto">
<Card className="shadow-sm border-0">
<CardHeader className="border-b border-gray-200 bg-white dark:bg-default-50 rounded-t-lg">
<CardHeader className="border-b border-gray-200 bg-white rounded-t-lg">
<CardTitle>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@ -140,7 +140,7 @@ const ReactTableImagePage = () => {
<UploadIcon className="w-4 h-4 text-blue-600" />
</div>
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
<h1 className="text-xl font-semibold text-gray-900">
Categories Management
</h1>
<p className="text-sm text-gray-500">
@ -294,7 +294,7 @@ const ReactTableImagePage = () => {
</div>
</CardTitle>
</CardHeader>
<CardContent className="p-6 bg-gray-50 dark:bg-default-50">
<CardContent className="p-6 bg-gray-50">
<TableCategories />
</CardContent>
</Card>

View File

@ -6,7 +6,7 @@ const page = () => {
return (
<div className="">
<SiteBreadcrumb />
<div className="bg-slate-100 dark:bg-default-50">
<div className="bg-slate-100">
<CategoriesUpdateForm />
</div>
</div>

View File

@ -12,7 +12,7 @@ const AudioTabs = () => {
<div className="w-full">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<div className="flex items-center justify-between mb-6">
{/* <TabsList className="inline-flex h-10 bg-gray-50 border border-gray-200 p-1 rounded-lg shadow-sm">
<TabsList className="inline-flex h-10 bg-gray-50 border border-gray-200 p-1 rounded-lg shadow-sm">
<TabsTrigger
value="submitted"
className="text-sm font-medium px-6 py-2 h-8 rounded-md transition-all duration-200 data-[state=active]:bg-white data-[state=active]:text-blue-600 data-[state=active]:shadow-sm data-[state=inactive]:text-gray-600 data-[state=inactive]:hover:text-gray-800"
@ -31,20 +31,20 @@ const AudioTabs = () => {
Waiting Approval
</div>
</TabsTrigger>
</TabsList> */}
</TabsList>
</div>
<TabsContent value="submitted" className="mt-0">
<div className="bg-white dark:bg-default-50 rounded-lg border border-gray-200 shadow-sm">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm">
<TableAudio />
</div>
</TabsContent>
{/* <TabsContent value="pending" className="mt-0">
<TabsContent value="pending" className="mt-0">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm">
<PendingApprovalTable typeId={4} />
</div>
</TabsContent> */}
</TabsContent>
</Tabs>
</div>
);

View File

@ -17,7 +17,6 @@ import { deleteArticle, deleteMedia } from "@/service/content/content";
import { error } from "@/lib/swal";
import Swal from "sweetalert2";
import Link from "next/link";
import { AccessGuard } from "@/components/access-guard";
const useTableColumns = () => {
const MySwal = withReactContent(Swal);
@ -138,119 +137,57 @@ const useTableColumns = () => {
);
},
},
{
accessorKey: "statusName",
header: "Status",
cell: ({ row }) => {
const {
statusId,
statusName,
isPublish,
reviewedAtLevel = "",
creatorGroupLevelId,
needApprovalFromLevel,
} = row.original;
const userLevelId = Number(getCookiesDecrypt("ulie"));
const userHasReviewed = reviewedAtLevel.includes(`:${userLevelId}:`);
const isCreator = Number(creatorGroupLevelId) === userLevelId;
if (isPublish) {
return (
<div className="flex items-center justify-center w-full h-full">
<Badge className="flex items-center justify-center min-w-[120px] rounded-full px-5 py-1 bg-green-100 text-green-700 text-center whitespace-nowrap">
Published
</Badge>
</div>
);
}
let label = statusName || "Menunggu Review";
if (statusId === 2 && !userHasReviewed && !isCreator) {
label = "Menunggu Review";
} else if (statusId === 1 && needApprovalFromLevel === userLevelId) {
label = "Menunggu Review";
} else if (statusId === 2) {
label = "Diterima";
}
const colors: Record<string, string> = {
"Menunggu Review": "bg-orange-100 text-orange-600",
Diterima: "bg-blue-100 text-blue-600",
Published: "bg-green-100 text-green-700",
Unknown: "bg-gray-100 text-gray-600",
default: "bg-gray-100 text-gray-600",
const statusColors: Record<string, string> = {
diterima: "bg-green-100 text-green-600",
"menunggu review": "bg-orange-100 text-orange-600",
};
const colors = [
"bg-orange-100 text-orange-600",
"bg-orange-100 text-orange-600",
"bg-green-100 text-green-600",
"bg-blue-100 text-blue-600",
"bg-red-200 text-red-600",
];
const status =
Number(row.original?.statusId) == 2 &&
row.original?.reviewedAtLevel !== null &&
!row.original?.reviewedAtLevel?.includes(`:${userLevelId}:`) &&
Number(row.original?.creatorGroupLevelId) != Number(userLevelId)
? "1"
: row.original?.statusId;
const statusStyles =
colors[Number(status)] || "bg-red-200 text-red-600";
// const statusStyles = statusColors[status] || "bg-red-200 text-red-600";
return (
<div className="flex items-center justify-center w-full h-full">
<Badge
className={cn(
"flex items-center justify-center min-w-[120px] rounded-full px-5 py-1 text-center whitespace-nowrap",
colors[label] || colors.default,
)}
>
{label}
</Badge>
</div>
<Badge
className={cn(
"rounded-full px-5 w-full whitespace-nowrap",
statusStyles
)}
>
{(Number(row.original?.statusId) == 2 &&
!row.original?.reviewedAtLevel !== null &&
!row.original?.reviewedAtLevel?.includes(
`:${Number(userLevelId)}:`
) &&
Number(row.original?.creatorGroupLevelId) !=
Number(userLevelId)) ||
(Number(row.original?.statusId) == 1 &&
Number(row.original?.needApprovalFromLevel) ==
Number(userLevelId))
? "Menunggu Review"
: row.original?.statusName}{" "}
</Badge>
);
},
},
// {
// accessorKey: "statusName",
// header: "Status",
// cell: ({ row }) => {
// const statusColors: Record<string, string> = {
// diterima: "bg-green-100 text-green-600",
// "menunggu review": "bg-orange-100 text-orange-600",
// };
// const colors = [
// "bg-orange-100 text-orange-600",
// "bg-orange-100 text-orange-600",
// "bg-green-100 text-green-600",
// "bg-blue-100 text-blue-600",
// "bg-red-200 text-red-600",
// ];
// const status =
// Number(row.original?.statusId) == 2 &&
// row.original?.reviewedAtLevel !== null &&
// !row.original?.reviewedAtLevel?.includes(`:${userLevelId}:`) &&
// Number(row.original?.creatorGroupLevelId) != Number(userLevelId)
// ? "1"
// : row.original?.statusId;
// const statusStyles =
// colors[Number(status)] || "bg-red-200 text-red-600";
// // const statusStyles = statusColors[status] || "bg-red-200 text-red-600";
// return (
// <Badge
// className={cn(
// "rounded-full px-5 w-full whitespace-nowrap",
// statusStyles
// )}
// >
// {(Number(row.original?.statusId) == 2 &&
// !row.original?.reviewedAtLevel !== null &&
// !row.original?.reviewedAtLevel?.includes(
// `:${Number(userLevelId)}:`
// ) &&
// Number(row.original?.creatorGroupLevelId) !=
// Number(userLevelId)) ||
// (Number(row.original?.statusId) == 1 &&
// Number(row.original?.needApprovalFromLevel) ==
// Number(userLevelId))
// ? "Menunggu Review"
// : row.original?.statusName}{" "}
// </Badge>
// );
// },
// },
{
id: "actions",
accessorKey: "action",
@ -304,7 +241,7 @@ const useTableColumns = () => {
React.useEffect(() => {
if (userLevelId !== undefined && roleId !== undefined) {
setIsMabesApprover(
Number(userLevelId) == 216 && Number(roleId) == 3,
Number(userLevelId) == 216 && Number(roleId) == 3
);
}
}, [userLevelId, roleId]);
@ -321,15 +258,12 @@ const useTableColumns = () => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="p-0" align="end">
<AccessGuard action="view">
<Link href={`/admin/content/audio/detail/${row.original.id}`}>
<DropdownMenuItem className="p-2 border-b text-default-700 group rounded-none">
<Eye className="w-4 h-4 me-1.5" />
View
</DropdownMenuItem>
</Link>
</AccessGuard>
<Link href={`/admin/content/audio/detail/${row.original.id}`}>
<DropdownMenuItem className="p-2 border-b text-default-700 group rounded-none">
<Eye className="w-4 h-4 me-1.5" />
View
</DropdownMenuItem>
</Link>
{/* <Link
href={`/admin/content/audio/update/${row.original.id}`}
>
@ -338,31 +272,22 @@ const useTableColumns = () => {
Edit
</DropdownMenuItem>
</Link> */}
{/* {(Number(row.original.uploadedById) === Number(userId) ||
isMabesApprover) && ( */}
<AccessGuard action="edit">
{(Number(row.original.uploadedById) === Number(userId) ||
isMabesApprover) && (
<Link href={`/admin/content/audio/update/${row.original.id}`}>
<DropdownMenuItem className="p-2 border-b text-default-700 group rounded-none">
<SquarePen className="w-4 h-4 me-1.5" />
Edit
</DropdownMenuItem>
</Link>
</AccessGuard>
{/* )} */}
<AccessGuard action="delete">
<DropdownMenuItem
onClick={() => handleDeleteMedia(row.original.id)}
className="p-2 border-b text-destructive bg-destructive/30 focus:bg-destructive focus:text-white rounded-none"
>
<Trash2 className="w-4 h-4 me-1.5 focus:text-white" />
Delete
</DropdownMenuItem>
</AccessGuard>
)}
<DropdownMenuItem
onClick={() => handleDeleteMedia(row.original.id)}
className="p-2 border-b text-destructive bg-destructive/30 focus:bg-destructive focus:text-white rounded-none"
>
<Trash2 className="w-4 h-4 me-1.5 focus:text-white" />
Delete
</DropdownMenuItem>
{/* {(row.original.uploadedById === userId || isMabesApprover) && (
<DropdownMenuItem
onClick={() => handleDeleteMedia(row.original.id)}

View File

@ -3,7 +3,7 @@ import FormAudio from "@/components/form/content/audio/audio-form";
const AudioCreatePage = async () => {
return (
<div>
<div className="space-y-4 bg-slate-100 dark:bg-default-50">
<div className="space-y-4 bg-slate-100">
<FormAudio />
</div>
</div>

View File

@ -5,7 +5,7 @@ const AudioDetailPage = async () => {
return (
<div>
<SiteBreadcrumb />
<div className="space-y-4 bg-slate-100 dark:bg-default-50">
<div className="space-y-4 bg-slate-100">
<FormAudioDetail />
</div>
</div>

View File

@ -5,19 +5,15 @@ import AudioTabs from "./components/audio-tabs";
import { UploadIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { usePermission } from "@/components/context/permission-context";
import { AccessGuard } from "@/components/access-guard";
const ReactTableAudioPage = () => {
const { can } = usePermission();
return (
<div className="min-h-screen bg-gray-50 dark:bg-default-50">
<div className="min-h-screen bg-gray-50">
<SiteBreadcrumb />
<div className="p-6">
<div className="max-w-7xl mx-auto">
<Card className="shadow-sm border-0">
<CardHeader className="border-b border-gray-200 bg-white dark:bg-default-50 rounded-t-lg">
<CardHeader className="border-b border-gray-200 bg-white rounded-t-lg">
<CardTitle>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@ -25,31 +21,22 @@ const ReactTableAudioPage = () => {
<UploadIcon className="w-4 h-4 text-blue-600" />
</div>
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
Audio Management
</h1>
<p className="text-sm text-gray-500">
Manage your submitted audio files and pending approvals
</p>
<h1 className="text-xl font-semibold text-gray-900">Audio Management</h1>
<p className="text-sm text-gray-500">Manage your submitted audio files and pending approvals</p>
</div>
</div>
<AccessGuard action="create">
<div className="flex-none">
<Link href={"/admin/content/audio/create"}>
<Button
color="primary"
className="text-white shadow-sm hover:shadow-md transition-shadow"
>
<UploadIcon size={18} className="mr-2" />
Create Audio
</Button>
</Link>
</div>
</AccessGuard>
<div className="flex-none">
<Link href={"/admin/content/audio/create"}>
<Button color="primary" className="text-white shadow-sm hover:shadow-md transition-shadow">
<UploadIcon size={18} className="mr-2" />
Create Audio
</Button>
</Link>
</div>
</div>
</CardTitle>
</CardHeader>
<CardContent className="p-6 bg-gray-50 dark:bg-default-50">
<CardContent className="p-6 bg-gray-50">
<AudioTabs />
</CardContent>
</Card>
@ -59,4 +46,4 @@ const ReactTableAudioPage = () => {
);
};
export default ReactTableAudioPage;
export default ReactTableAudioPage;

View File

@ -19,7 +19,6 @@ import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import Link from "next/link";
import { deleteArticle } from "@/service/content/content";
import { AccessGuard } from "@/components/access-guard";
const useTableColumns = () => {
const MySwal = withReactContent(Swal);
@ -67,10 +66,7 @@ const useTableColumns = () => {
accessorKey: "createdAt",
header: "Upload Date",
cell: ({ row }) => {
const createdAt = row.getValue("createdAt") as
| string
| number
| undefined;
const createdAt = row.getValue("createdAt") as string | number | undefined;
const formattedDate =
createdAt && !isNaN(new Date(createdAt).getTime())
? format(new Date(createdAt), "dd-MM-yyyy HH:mm:ss")
@ -102,8 +98,7 @@ const useTableColumns = () => {
cell: ({ row }) => {
const isPublish = row.original.isPublish;
const isPublishOnPolda = row.original.isPublishOnPolda;
const creatorGroupParentLevelId =
row.original.creatorGroupParentLevelId;
const creatorGroupParentLevelId = row.original.creatorGroupParentLevelId;
let displayText = "-";
if (isPublish && !isPublishOnPolda) {
@ -129,100 +124,40 @@ const useTableColumns = () => {
);
},
},
// {
// accessorKey: "statusName",
// header: "Status",
// cell: ({ row }) => {
// const statusId = Number(row.original?.statusId);
// const reviewedAtLevel = row.original?.reviewedAtLevel || "";
// const creatorGroupLevelId = Number(row.original?.creatorGroupLevelId);
// const needApprovalFromLevel = Number(row.original?.needApprovalFromLevel);
// const userHasReviewed = reviewedAtLevel.includes(`:${userLevelId}:`);
// const isCreator = creatorGroupLevelId === Number(userLevelId);
// const isWaitingForReview = statusId === 2 && !userHasReviewed && !isCreator;
// const isApprovalNeeded = statusId === 1 && needApprovalFromLevel === Number(userLevelId);
// const label =
// isWaitingForReview || isApprovalNeeded
// ? "Menunggu Review"
// : statusId === 2
// ? "Diterima"
// : row.original?.statusName;
// const colors: Record<string, string> = {
// "Menunggu Review": "bg-orange-100 text-orange-600",
// Diterima: "bg-green-100 text-green-600",
// default: "bg-red-200 text-red-600",
// };
// const statusStyles = colors[label] || colors.default;
// return (
// <Badge className={cn("rounded-full px-5 w-full whitespace-nowrap", statusStyles)}>
// {label}
// </Badge>
// );
// },
// },
{
accessorKey: "statusName",
header: "Status",
cell: ({ row }) => {
const {
statusId,
statusName,
isPublish,
reviewedAtLevel = "",
creatorGroupLevelId,
needApprovalFromLevel,
} = row.original;
const userLevelId = Number(getCookiesDecrypt("ulie"));
const statusId = Number(row.original?.statusId);
const reviewedAtLevel = row.original?.reviewedAtLevel || "";
const creatorGroupLevelId = Number(row.original?.creatorGroupLevelId);
const needApprovalFromLevel = Number(row.original?.needApprovalFromLevel);
const userHasReviewed = reviewedAtLevel.includes(`:${userLevelId}:`);
const isCreator = Number(creatorGroupLevelId) === userLevelId;
const isCreator = creatorGroupLevelId === Number(userLevelId);
if (isPublish) {
return (
<div className="flex items-center justify-center w-full h-full">
<Badge className="flex items-center justify-center min-w-[120px] rounded-full px-5 py-1 bg-green-100 text-green-700 text-center whitespace-nowrap">
Published
</Badge>
</div>
);
}
const isWaitingForReview = statusId === 2 && !userHasReviewed && !isCreator;
const isApprovalNeeded = statusId === 1 && needApprovalFromLevel === Number(userLevelId);
let label = statusName || "Menunggu Review";
if (statusId === 2 && !userHasReviewed && !isCreator) {
label = "Menunggu Review";
} else if (statusId === 1 && needApprovalFromLevel === userLevelId) {
label = "Menunggu Review";
} else if (statusId === 2) {
label = "Diterima";
}
const label =
isWaitingForReview || isApprovalNeeded
? "Menunggu Review"
: statusId === 2
? "Diterima"
: row.original?.statusName;
const colors: Record<string, string> = {
"Menunggu Review": "bg-orange-100 text-orange-600",
Diterima: "bg-blue-100 text-blue-600",
Published: "bg-green-100 text-green-700",
Unknown: "bg-gray-100 text-gray-600",
default: "bg-gray-100 text-gray-600",
Diterima: "bg-green-100 text-green-600",
default: "bg-red-200 text-red-600",
};
const statusStyles = colors[label] || colors.default;
return (
<div className="flex items-center justify-center w-full h-full">
<Badge
className={cn(
"flex items-center justify-center min-w-[120px] rounded-full px-5 py-1 text-center whitespace-nowrap",
colors[label] || colors.default,
)}
>
{label}
</Badge>
</div>
<Badge className={cn("rounded-full px-5 w-full whitespace-nowrap", statusStyles)}>
{label}
</Badge>
);
},
},
@ -280,9 +215,7 @@ const useTableColumns = () => {
React.useEffect(() => {
if (userLevelId !== undefined && roleId !== undefined) {
setIsMabesApprover(
Number(userLevelId) === 216 && Number(roleId) === 3,
);
setIsMabesApprover(Number(userLevelId) === 216 && Number(roleId) === 3);
}
}, [userLevelId, roleId]);
@ -291,45 +224,37 @@ const useTableColumns = () => {
<DropdownMenuTrigger asChild>
<Button
size="icon"
className="bg-transparent ring-offset-transparent hover:bg-transparent hover:ring-0 hover:ring-transparent cursor-pointer"
className="bg-transparent ring-offset-transparent hover:bg-transparent hover:ring-0 hover:ring-transparent"
>
<span className="sr-only">Open menu</span>
<MoreVertical className="h-4 w-4 text-default-800" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="p-0 hover:text-black" align="end">
<AccessGuard action="view">
<Link
href={`/admin/content/image/detail/${row.original.id}`}
className="hover:text-black"
>
<DropdownMenuItem className="p-2 border-b text-default-700 rounded-none cursor-pointer hover:bg-slate-200">
<Eye className="w-4 h-4 me-1.5" />
View
</DropdownMenuItem>
</Link>
</AccessGuard>
<Link
href={`/admin/content/image/detail/${row.original.id}`}
className="hover:text-black"
>
<DropdownMenuItem className="p-2 border-b text-default-700 rounded-none">
<Eye className="w-4 h-4 me-1.5" />
View
</DropdownMenuItem>
</Link>
{/* {(Number(row.original.uploadedById) === Number(userId) || isMabesApprover) && ( */}
<AccessGuard action="edit">
<Link href={`/admin/content/image/update/${row.original.id}`}>
<DropdownMenuItem className="p-2 border-b text-default-700 rounded-none cursor-pointer hover:bg-slate-200">
<DropdownMenuItem className="p-2 border-b text-default-700 rounded-none">
<SquarePen className="w-4 h-4 me-1.5" />
Edit
</DropdownMenuItem>
</Link>
</AccessGuard>
{/* )} */}
<AccessGuard action="delete">
<DropdownMenuItem
onClick={() => handleDeleteMedia(row.original.id)}
className="p-2 border-b text-destructive bg-destructive/30 focus:bg-destructive focus:text-white rounded-none cursor-pointer"
>
<Trash2 className="w-4 h-4 me-1.5" />
Delete
</DropdownMenuItem>
</AccessGuard>
<DropdownMenuItem
onClick={() => handleDeleteMedia(row.original.id)}
className="p-2 border-b text-destructive bg-destructive/30 focus:bg-destructive focus:text-white rounded-none"
>
<Trash2 className="w-4 h-4 me-1.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@ -6,12 +6,12 @@ import TableImage from "./table-image";
import PendingApprovalTable from "./pending-approval-table";
const ImageTabs = () => {
const [activeTab, setActiveTab] = React.useState("submitted");
const [activeTab, setActiveTab] = React.useState("pending");
return (
<div className="w-full">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
{/* <div className="flex items-center justify-between mb-6">
<div className="flex items-center justify-between mb-6">
<TabsList className="inline-flex h-10 bg-gray-50 border border-gray-200 p-1 rounded-lg shadow-sm">
<TabsTrigger
value="pending"
@ -33,18 +33,18 @@ const ImageTabs = () => {
</TabsTrigger>
</TabsList>
</div>
*/}
<TabsContent value="submitted" className="mt-0">
<div className="bg-white dark:bg-default-50 rounded-lg border border-gray-200 shadow-sm">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm">
<TableImage />
</div>
</TabsContent>
{/* <TabsContent value="pending" className="mt-0">
<TabsContent value="pending" className="mt-0">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm">
<PendingApprovalTable typeId={1} />
</div>
</TabsContent> */}
</TabsContent>
</Tabs>
</div>
);

View File

@ -176,7 +176,7 @@ const usePendingApprovalColumns = () => {
<DropdownMenuTrigger asChild>
<Button
size="icon"
className="bg-transparent ring-offset-transparent hover:bg-transparent hover:ring-0 hover:ring-transparent cursor-pointer"
className="bg-transparent ring-offset-transparent hover:bg-transparent hover:ring-0 hover:ring-transparent"
>
<span className="sr-only">Open menu</span>
<MoreVertical className="h-4 w-4 text-default-800" />
@ -187,7 +187,7 @@ const usePendingApprovalColumns = () => {
href={`/admin/content/image/detail/${row.original.id}`}
className="hover:text-black"
>
<DropdownMenuItem className="p-2 border-b text-default-700 group rounded-none cursor-pointer hover:bg-slate-200">
<DropdownMenuItem className="p-2 border-b text-default-700 group rounded-none">
<Eye className="w-4 h-4 me-1.5" />
View
</DropdownMenuItem>

View File

@ -84,7 +84,7 @@ const TableImage = () => {
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const [showData, setShowData] = React.useState("10");
const [showData, setShowData] = React.useState("50");
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: Number(showData),
@ -235,7 +235,7 @@ const TableImage = () => {
totalPage: Number(showData),
title: search || undefined,
categoryId: categoryFilter ? Number(categoryFilter) : undefined,
typeId: 1,
typeId: 1, // image content typeoriginalRows
statusId: statusFilter?.length > 0 ? Number(statusFilter[0]) : undefined,
startDate: formattedStartDate || undefined,
endDate: formattedEndDate || undefined,
@ -290,9 +290,9 @@ const TableImage = () => {
return (
<div className="w-full overflow-x-auto">
<div className="flex flex-col md:flex-row lg:flex-row md:justify-between lg:justify-between items-center md:px-5 lg:px-5">
<div className="relative w-full md:w-[200px] lg:w-[200px] px-2 border border-slate-200">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 " />
<input
<div className="relative w-full md:w-[200px] lg:w-[200px] px-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 dark:text-white" />
<Input
type="text"
placeholder="Search Judul..."
className="pl-9 bg-transparent dark:border-secondary dark:placeholder-white/80 dark:focus:border-secondary dark:text-white"

View File

@ -5,7 +5,7 @@ const ImageCreatePage = async () => {
return (
<div>
<SiteBreadcrumb />
<div className="space-y-4 bg-slate-100 dark:bg-default-50">
<div className="space-y-4 bg-slate-100">
<FormImage />
</div>
</div>

View File

@ -5,7 +5,7 @@ const ImageDetailPage = async () => {
return (
<div>
<SiteBreadcrumb />
<div className="space-y-4 bg-slate-100 dark:bg-default-50">
<div className="space-y-4 bg-slate-100">
<FormImageDetail />
</div>
</div>

View File

@ -5,19 +5,15 @@ import ImageTabs from "./components/image-tabs";
import { UploadIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { usePermission } from "@/components/context/permission-context";
import { AccessGuard } from "@/components/access-guard";
const ReactTableImagePage = () => {
const { can } = usePermission();
return (
<div className="min-h-screen bg-gray-50 dark:bg-default-50">
<div className="min-h-screen bg-gray-50">
<SiteBreadcrumb />
<div className="p-6">
<div className="max-w-7xl mx-auto">
<Card className="shadow-sm border-0">
<CardHeader className="border-b border-gray-200 bg-white dark:bg-default-50 rounded-t-lg">
<CardHeader className="border-b border-gray-200 bg-white rounded-t-lg">
<CardTitle>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@ -25,37 +21,28 @@ const ReactTableImagePage = () => {
<UploadIcon className="w-4 h-4 text-blue-600" />
</div>
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
Image Management
</h1>
<p className="text-sm text-gray-500">
Manage your submitted images and pending approvals
</p>
<h1 className="text-xl font-semibold text-gray-900">Image Management</h1>
<p className="text-sm text-gray-500">Manage your submitted images and pending approvals</p>
</div>
</div>
<AccessGuard action="create">
<div className="flex-none">
<Link href={"/admin/content/image/create"}>
<Button
color="primary"
className="text-white shadow-sm hover:shadow-md transition-shadow cursor-pointer"
>
<UploadIcon size={18} className="mr-2" />
Create Image
</Button>
</Link>
{/* <Link href={"/contributor/content/image/createAi"}>
<div className="flex-none">
<Link href={"/admin/content/image/create"}>
<Button color="primary" className="text-white shadow-sm hover:shadow-md transition-shadow">
<UploadIcon size={18} className="mr-2" />
Create Image
</Button>
</Link>
{/* <Link href={"/contributor/content/image/createAi"}>
<Button color="primary" className="text-white ml-3">
<UploadIcon />
Unggah Foto Dengan AI
</Button>
</Link> */}
</div>
</AccessGuard>
</div>
</div>
</CardTitle>
</CardHeader>
<CardContent className="p-6 bg-gray-50 dark:bg-default-50">
<CardContent className="p-6 bg-gray-50">
<ImageTabs />
</CardContent>
</Card>

View File

@ -17,7 +17,6 @@ import { deleteArticle, deleteMedia } from "@/service/content/content";
import withReactContent from "sweetalert2-react-content";
import Swal from "sweetalert2";
import Link from "next/link";
import { AccessGuard } from "@/components/access-guard";
const useTableColumns = () => {
const MySwal = withReactContent(Swal);
@ -143,59 +142,50 @@ const useTableColumns = () => {
accessorKey: "statusName",
header: "Status",
cell: ({ row }) => {
const {
statusId,
statusName,
isPublish,
reviewedAtLevel = "",
creatorGroupLevelId,
needApprovalFromLevel,
} = row.original;
const userLevelId = Number(getCookiesDecrypt("ulie"));
const userHasReviewed = reviewedAtLevel.includes(`:${userLevelId}:`);
const isCreator = Number(creatorGroupLevelId) === userLevelId;
if (isPublish) {
return (
<div className="flex items-center justify-center w-full h-full">
<Badge className="flex items-center justify-center min-w-[120px] rounded-full px-5 py-1 bg-green-100 text-green-700 text-center whitespace-nowrap">
Published
</Badge>
</div>
);
}
let label = statusName || "Menunggu Review";
if (statusId === 2 && !userHasReviewed && !isCreator) {
label = "Menunggu Review";
} else if (statusId === 1 && needApprovalFromLevel === userLevelId) {
label = "Menunggu Review";
} else if (statusId === 2) {
label = "Diterima";
}
const colors: Record<string, string> = {
"Menunggu Review": "bg-orange-100 text-orange-600",
Diterima: "bg-blue-100 text-blue-600",
Published: "bg-green-100 text-green-700",
Unknown: "bg-gray-100 text-gray-600",
default: "bg-gray-100 text-gray-600",
const statusColors: Record<string, string> = {
diterima: "bg-green-100 text-green-600",
"menunggu review": "bg-orange-100 text-orange-600",
};
const colors = [
"bg-orange-100 text-orange-600",
"bg-orange-100 text-orange-600",
"bg-green-100 text-green-600",
"bg-blue-100 text-blue-600",
"bg-red-200 text-red-600",
];
const status =
Number(row.original?.statusId) == 2 &&
row.original?.reviewedAtLevel !== null &&
!row.original?.reviewedAtLevel?.includes(`:${userLevelId}:`) &&
Number(row.original?.creatorGroupLevelId) != Number(userLevelId)
? "1"
: row.original?.statusId;
const statusStyles =
colors[Number(status)] || "bg-red-200 text-red-600";
// const statusStyles = statusColors[status] || "bg-red-200 text-red-600";
return (
<div className="flex items-center justify-center w-full h-full">
<Badge
className={cn(
"flex items-center justify-center min-w-[120px] rounded-full px-5 py-1 text-center whitespace-nowrap",
colors[label] || colors.default,
)}
>
{label}
</Badge>
</div>
<Badge
className={cn(
"rounded-full px-5 w-full whitespace-nowrap",
statusStyles
)}
>
{(Number(row.original?.statusId) == 2 &&
!row.original?.reviewedAtLevel !== null &&
!row.original?.reviewedAtLevel?.includes(
`:${Number(userLevelId)}:`
) &&
Number(row.original?.creatorGroupLevelId) !=
Number(userLevelId)) ||
(Number(row.original?.statusId) == 1 &&
Number(row.original?.needApprovalFromLevel) ==
Number(userLevelId))
? "Menunggu Review"
: row.original?.statusName}{" "}
</Badge>
);
},
},
@ -252,7 +242,7 @@ const useTableColumns = () => {
React.useEffect(() => {
if (userLevelId !== undefined && roleId !== undefined) {
setIsMabesApprover(
Number(userLevelId) == 216 && Number(roleId) == 3,
Number(userLevelId) == 216 && Number(roleId) == 3
);
}
}, [userLevelId, roleId]);
@ -269,37 +259,28 @@ const useTableColumns = () => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="p-0" align="end">
<AccessGuard action="view">
<Link href={`/admin/content/text/detail/${row.original.id}`}>
<DropdownMenuItem className="p-2 border-b text-default-700 group rounded-none">
<Eye className="w-4 h-4 me-1.5" />
View
</DropdownMenuItem>
</Link>
</AccessGuard>
{/* {(Number(row.original.uploadedById) === Number(userId) ||
isMabesApprover) && ( */}
<AccessGuard action="edit">
<Link href={`/admin/content/text/detail/${row.original.id}`}>
<DropdownMenuItem className="p-2 border-b text-default-700 group rounded-none">
<Eye className="w-4 h-4 me-1.5" />
View
</DropdownMenuItem>
</Link>
{(Number(row.original.uploadedById) === Number(userId) ||
isMabesApprover) && (
<Link href={`/admin/content/text/update/${row.original.id}`}>
<DropdownMenuItem className="p-2 border-b text-default-700 group rounded-none">
<DropdownMenuItem className="p-2 border-b text-default-700 group rounded-none">
<SquarePen className="w-4 h-4 me-1.5" />
Edit
</DropdownMenuItem>
</Link>
</AccessGuard>
{/* )} */}
<AccessGuard action="delete">
<DropdownMenuItem
onClick={() => handleDeleteMedia(row.original.id)}
className="p-2 border-b text-destructive bg-destructive/30 focus:bg-destructive focus:text-white rounded-none"
>
<Trash2 className="w-4 h-4 me-1.5 focus:text-white" />
Delete
</DropdownMenuItem>
</AccessGuard>
)}
<DropdownMenuItem
onClick={() => handleDeleteMedia(row.original.id)}
className="p-2 border-b text-destructive bg-destructive/30 focus:bg-destructive focus:text-white rounded-none"
>
<Trash2 className="w-4 h-4 me-1.5 focus:text-white" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@ -12,7 +12,7 @@ const DocumentTabs = () => {
<div className="w-full">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<div className="flex items-center justify-between mb-6">
{/* <TabsList className="inline-flex h-10 bg-gray-50 border border-gray-200 p-1 rounded-lg shadow-sm">
<TabsList className="inline-flex h-10 bg-gray-50 border border-gray-200 p-1 rounded-lg shadow-sm">
<TabsTrigger
value="submitted"
className="text-sm font-medium px-6 py-2 h-8 rounded-md transition-all duration-200 data-[state=active]:bg-white data-[state=active]:text-blue-600 data-[state=active]:shadow-sm data-[state=inactive]:text-gray-600 data-[state=inactive]:hover:text-gray-800"
@ -31,20 +31,20 @@ const DocumentTabs = () => {
Waiting Approval
</div>
</TabsTrigger>
</TabsList> */}
</TabsList>
</div>
<TabsContent value="submitted" className="mt-0">
<div className="bg-white dark:bg-default-50 rounded-lg border border-gray-200 shadow-sm">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm">
<TableTeks />
</div>
</TabsContent>
{/* <TabsContent value="pending" className="mt-0">
<TabsContent value="pending" className="mt-0">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm">
<PendingApprovalTable typeId={3} />
</div>
</TabsContent> */}
</TabsContent>
</Tabs>
</div>
);

View File

@ -5,7 +5,7 @@ const TeksCreatePage = async () => {
return (
<div>
<SiteBreadcrumb />
<div className="space-y-4 bg-slate-100 dark:bg-default-50">
<div className="space-y-4 bg-slate-100">
<FormTeks />
</div>
</div>

View File

@ -5,7 +5,7 @@ const TeksDetailPage = async () => {
return (
<div>
<SiteBreadcrumb />
<div className="space-y-4 bg-slate-100 dark:bg-default-50">
<div className="space-y-4 bg-slate-100">
<FormTeksDetail />
</div>
</div>

View File

@ -5,19 +5,15 @@ import DocumentTabs from "./components/document-tabs";
import { UploadIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { usePermission } from "@/components/context/permission-context";
import { AccessGuard } from "@/components/access-guard";
const ReactTableDocumentPage = () => {
const { can } = usePermission();
return (
<div className="min-h-screen bg-gray-50 dark:bg-default-50">
<SiteBreadcrumb />
<div className="min-h-screen bg-gray-50">
{/* <SiteBreadcrumb /> */}
<div className="p-6">
<div className="max-w-7xl mx-auto">
<Card className="shadow-sm border-0">
<CardHeader className="border-b border-gray-200 bg-white dark:bg-default-50 rounded-t-lg">
<CardHeader className="border-b border-gray-200 bg-white rounded-t-lg">
<CardTitle>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@ -25,32 +21,22 @@ const ReactTableDocumentPage = () => {
<UploadIcon className="w-4 h-4 text-blue-600" />
</div>
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
Document Management
</h1>
<p className="text-sm text-gray-500">
Manage your submitted documents and pending approvals
</p>
<h1 className="text-xl font-semibold text-gray-900">Document Management</h1>
<p className="text-sm text-gray-500">Manage your submitted documents and pending approvals</p>
</div>
</div>
<AccessGuard action="create">
{" "}
<div className="flex-none">
<Link href={"/admin/content/text/create"}>
<Button
color="primary"
className="text-white shadow-sm hover:shadow-md transition-shadow"
>
<UploadIcon size={18} className="mr-2" />
Create Document
</Button>
</Link>
</div>
</AccessGuard>
<div className="flex-none">
<Link href={"/admin/content/text/create"}>
<Button color="primary" className="text-white shadow-sm hover:shadow-md transition-shadow">
<UploadIcon size={18} className="mr-2" />
Create Document
</Button>
</Link>
</div>
</div>
</CardTitle>
</CardHeader>
<CardContent className="p-6 bg-gray-50 dark:bg-default-50">
<CardContent className="p-6 bg-gray-50">
<DocumentTabs />
</CardContent>
</Card>

View File

@ -11,7 +11,7 @@ const AudioVisualTabs = () => {
return (
<div className="w-full">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
{/* <div className="flex items-center justify-between mb-6">
<div className="flex items-center justify-between mb-6">
<TabsList className="inline-flex h-10 bg-gray-50 border border-gray-200 p-1 rounded-lg shadow-sm">
<TabsTrigger
value="submitted"
@ -32,19 +32,19 @@ const AudioVisualTabs = () => {
</div>
</TabsTrigger>
</TabsList>
</div> */}
</div>
<TabsContent value="submitted" className="mt-0">
<div className="bg-white dark:bg-default-50 rounded-lg border border-gray-200 shadow-sm">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm">
<TableVideo />
</div>
</TabsContent>
{/* <TabsContent value="pending" className="mt-0">
<TabsContent value="pending" className="mt-0">
<div className="bg-white rounded-lg border border-gray-200 shadow-sm">
<PendingApprovalTable typeId={2} />
</div>
</TabsContent> */}
</TabsContent>
</Tabs>
</div>
);

View File

@ -18,7 +18,6 @@ import { error } from "@/lib/swal";
import Link from "next/link";
import { deleteMedia } from "@/service/content";
import { deleteArticle } from "@/service/content/content";
import { AccessGuard } from "@/components/access-guard";
const useTableColumns = () => {
const MySwal = withReactContent(Swal);
@ -195,59 +194,45 @@ const useTableColumns = () => {
accessorKey: "statusName",
header: "Status",
cell: ({ row }) => {
const {
statusId,
statusName,
isPublish,
reviewedAtLevel = "",
creatorGroupLevelId,
needApprovalFromLevel,
} = row.original;
const userLevelId = Number(getCookiesDecrypt("ulie"));
const statusId = Number(row.original?.statusId);
const reviewedAtLevel = row.original?.reviewedAtLevel || "";
const creatorGroupLevelId = Number(row.original?.creatorGroupLevelId);
const needApprovalFromLevel = Number(
row.original?.needApprovalFromLevel
);
const userHasReviewed = reviewedAtLevel.includes(`:${userLevelId}:`);
const isCreator = Number(creatorGroupLevelId) === userLevelId;
const isCreator = creatorGroupLevelId === Number(userLevelId);
if (isPublish) {
return (
<div className="flex items-center justify-center w-full h-full">
<Badge className="flex items-center justify-center min-w-[120px] rounded-full px-5 py-1 bg-green-100 text-green-700 text-center whitespace-nowrap">
Published
</Badge>
</div>
);
}
const isWaitingForReview =
statusId === 2 && !userHasReviewed && !isCreator;
const isApprovalNeeded =
statusId === 1 && needApprovalFromLevel === Number(userLevelId);
let label = statusName || "Menunggu Review";
if (statusId === 2 && !userHasReviewed && !isCreator) {
label = "Menunggu Review";
} else if (statusId === 1 && needApprovalFromLevel === userLevelId) {
label = "Menunggu Review";
} else if (statusId === 2) {
label = "Diterima";
}
const label =
isWaitingForReview || isApprovalNeeded
? "Menunggu Review"
: statusId === 2
? "Diterima"
: row.original?.statusName;
const colors: Record<string, string> = {
"Menunggu Review": "bg-orange-100 text-orange-600",
Diterima: "bg-blue-100 text-blue-600",
Published: "bg-green-100 text-green-700",
Unknown: "bg-gray-100 text-gray-600",
default: "bg-gray-100 text-gray-600",
Diterima: "bg-green-100 text-green-600",
default: "bg-red-200 text-red-600",
};
const statusStyles = colors[label] || colors.default;
return (
<div className="flex items-center justify-center w-full h-full">
<Badge
className={cn(
"flex items-center justify-center min-w-[120px] rounded-full px-5 py-1 text-center whitespace-nowrap",
colors[label] || colors.default,
)}
>
{label}
</Badge>
</div>
<Badge
className={cn(
"rounded-full px-5 w-full whitespace-nowrap",
statusStyles
)}
>
{label}
</Badge>
);
},
},
@ -304,7 +289,7 @@ const useTableColumns = () => {
React.useEffect(() => {
if (userLevelId !== undefined && roleId !== undefined) {
setIsMabesApprover(
Number(userLevelId) == 216 && Number(roleId) == 3,
Number(userLevelId) == 216 && Number(roleId) == 3
);
}
}, [userLevelId, roleId]);
@ -321,15 +306,12 @@ const useTableColumns = () => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="p-0" align="end">
<AccessGuard action="view">
<Link href={`/admin/content/video/detail/${row.original.id}`}>
<DropdownMenuItem className="p-2 border-b text-default-700 group rounded-none">
<Eye className="w-4 h-4 me-1.5" />
View
</DropdownMenuItem>
</Link>
</AccessGuard>
<Link href={`/admin/content/video/detail/${row.original.id}`}>
<DropdownMenuItem className="p-2 border-b text-default-700 group rounded-none">
<Eye className="w-4 h-4 me-1.5" />
View
</DropdownMenuItem>
</Link>
{/* <Link
href={`/contributor/content/video/update/${row.original.id}`}
>
@ -340,27 +322,20 @@ const useTableColumns = () => {
</Link> */}
{/* {(Number(row.original.uploadedById) === Number(userId) ||
isMabesApprover) && ( */}
<AccessGuard action="edit">
<Link href={`/admin/content/video/update/${row.original.id}`}>
<DropdownMenuItem className="p-2 border-b text-default-700 group rounded-none">
<SquarePen className="w-4 h-4 me-1.5" />
Edit
</DropdownMenuItem>
</Link>
</AccessGuard>
{/* )} */}
<AccessGuard action="delete">
<DropdownMenuItem
onClick={() => handleDeleteMedia(row.original.id)}
className="p-2 border-b text-destructive bg-destructive/30 focus:bg-destructive focus:text-white rounded-none"
>
<Trash2 className="w-4 h-4 me-1.5 focus:text-white" />
Delete
<Link href={`/admin/content/video/update/${row.original.id}`}>
<DropdownMenuItem className="p-2 border-b text-default-700 group rounded-none">
<SquarePen className="w-4 h-4 me-1.5" />
Edit
</DropdownMenuItem>
</AccessGuard>
</Link>
{/* )} */}
<DropdownMenuItem
onClick={() => handleDeleteMedia(row.original.id)}
className="p-2 border-b text-destructive bg-destructive/30 focus:bg-destructive focus:text-white rounded-none"
>
<Trash2 className="w-4 h-4 me-1.5 focus:text-white" />
Delete
</DropdownMenuItem>
{/* {(row.original.uploadedById === userId || isMabesApprover) && (
<DropdownMenuItem
onClick={() => handleDeleteMedia(row.original.id)}

View File

@ -72,14 +72,18 @@ const TableVideo = () => {
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const [showData, setShowData] = React.useState("10");
const [showData, setShowData] = React.useState("50");
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: Number(showData),
});
const [page, setPage] = React.useState(1);
const [totalPage, setTotalPage] = React.useState(1);
const [limit, setLimit] = React.useState(10);
const [search, setSearch] = React.useState<string>("");
const userId = getCookiesDecrypt("uie");
const userLevelId = getCookiesDecrypt("ulie");
const [categories, setCategories] = React.useState<any[]>([]);
const [selectedCategories, setSelectedCategories] = React.useState<number[]>(
[]
@ -91,6 +95,7 @@ const TableVideo = () => {
const [filterByCreator, setFilterByCreator] = React.useState("");
const [filterBySource, setFilterBySource] = React.useState("");
const [filterByCreatorGroup, setFilterByCreatorGroup] = React.useState("");
const roleId = getCookiesDecrypt("urie");
const columns = useTableColumns();
const table = useReactTable({

View File

@ -5,7 +5,7 @@ const VideoCreatePage = async () => {
return (
<div>
<SiteBreadcrumb />
<div className="space-y-4 bg-slate-100 dark:bg-default-50">
<div className="space-y-4 bg-slate-100">
<FormVideo />
</div>
</div>

View File

@ -5,7 +5,7 @@ const VideoDetailPage = async () => {
return (
<div>
<SiteBreadcrumb />
<div className="space-y-4 bg-slate-100 dark:bg-default-50">
<div className="space-y-4 bg-slate-100">
<FormVideoDetail />
</div>
</div>

View File

@ -5,19 +5,15 @@ import AudioVisualTabs from "./components/audio-visual-tabs";
import { UploadIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { usePermission } from "@/components/context/permission-context";
import { AccessGuard } from "@/components/access-guard";
const ReactTableAudioVisualPage = () => {
const { can } = usePermission();
return (
<div className="min-h-screen bg-gray-50 dark:bg-default-50">
<div className="min-h-screen bg-gray-50">
<SiteBreadcrumb />
<div className="p-6">
<div className="max-w-7xl mx-auto">
<Card className="shadow-sm border-0">
<CardHeader className="border-b border-gray-200 bg-white dark:bg-default-50 rounded-t-lg">
<CardHeader className="border-b border-gray-200 bg-white rounded-t-lg">
<CardTitle>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@ -25,32 +21,22 @@ const ReactTableAudioVisualPage = () => {
<UploadIcon className="w-4 h-4 text-blue-600" />
</div>
<div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
Audio-Visual Management
</h1>
<p className="text-sm text-gray-500">
Manage your submitted audio-visual files and pending
approvals
</p>
<h1 className="text-xl font-semibold text-gray-900">Audio-Visual Management</h1>
<p className="text-sm text-gray-500">Manage your submitted audio-visual files and pending approvals</p>
</div>
</div>
<AccessGuard action="create">
<div className="flex-none">
<Link href={"/admin/content/video/create"}>
<Button
color="primary"
className="text-white shadow-sm hover:shadow-md transition-shadow"
>
<UploadIcon size={18} className="mr-2" />
Create Audio-Visual
</Button>
</Link>
</div>
</AccessGuard>
<div className="flex-none">
<Link href={"/admin/content/video/create"}>
<Button color="primary" className="text-white shadow-sm hover:shadow-md transition-shadow">
<UploadIcon size={18} className="mr-2" />
Create Audio-Visual
</Button>
</Link>
</div>
</div>
</CardTitle>
</CardHeader>
<CardContent className="p-6 bg-gray-50 dark:bg-default-50">
<CardContent className="p-6 bg-gray-50">
<AudioVisualTabs />
</CardContent>
</Card>
@ -60,4 +46,4 @@ const ReactTableAudioVisualPage = () => {
);
};
export default ReactTableAudioVisualPage;
export default ReactTableAudioVisualPage;

View File

@ -21,7 +21,7 @@ export default function AdminPage() {
return (
<motion.div
className="h-full overflow-auto"
className="h-full overflow-auto bg-gray-50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}

View File

@ -150,10 +150,10 @@ export default function UserDetailPage() {
<p className="text-lg font-mono">{user.statusId}</p>
</div>
{/* <div>
<div>
<label className="text-sm font-medium text-gray-600">Keycloak ID</label>
<p className="text-xs font-mono break-all p-2 rounded">{user.keycloakId}</p>
</div> */}
<p className="text-xs font-mono break-all bg-gray-100 p-2 rounded">{user.keycloakId}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-600">Dibuat Oleh</label>
@ -185,10 +185,10 @@ export default function UserDetailPage() {
<p className="text-lg">{formatDateToIndonesian(user.updatedAt)}</p>
</div>
{/* <div>
<div>
<label className="text-sm font-medium text-gray-600">Foto Profil</label>
<p className="text-lg">{user.profilePicturePath ? "Tersedia" : "Tidak ada"}</p>
</div> */}
</div>
</div>
{/* Additional Information (only show if data exists) */}

View File

@ -1,783 +1,48 @@
"use client";
import SiteBreadcrumb from "@/components/site-breadcrumb";
import { zodResolver } from "@hookform/resolvers/zod";
import { Check, ChevronsUpDown } from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useRouter } from "@/i18n/routing";
import UserForm from "@/components/form/user/user-form";
import { useSearchParams } from "next/navigation";
import { cn, getCookiesDecrypt } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
AdministrationLevelList,
getListCompetencies,
getListEducation,
getListSchools,
getUserById,
updateUserInternal,
} from "@/service/management-user/management-user";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Link, useRouter } from "@/i18n/routing";
import dynamic from "next/dynamic";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import { close, error, loading } from "@/config/swal";
import { useParams } from "next/navigation";
import { getUserLevels } from "@/service/approval-workflows";
const PasswordChecklist = dynamic(() => import("react-password-checklist"), {
ssr: false,
});
const sns = [
{
key: 1,
id: "comment",
typeId: 1,
name: "Komentar Konten",
},
{
key: 2,
id: "fb",
typeId: 2,
name: "Facebook",
},
{
key: 3,
id: "ig",
typeId: 3,
name: "Instagram",
},
{
key: 4,
id: "twt",
typeId: 4,
name: "Twitter",
},
{
key: 5,
id: "yt",
typeId: 5,
name: "Youtube",
},
{
key: 6,
id: "emergency",
typeId: 6,
name: "Emergency Issue",
},
{
key: 7,
id: "email",
typeId: 7,
name: "Email",
},
{
key: 8,
id: "inbox",
typeId: 8,
name: "Pesan Masuk",
},
{
key: 9,
id: "whatsapp",
typeId: 9,
name: "Whatssapp",
},
{
key: 10,
id: "tiktok",
typeId: 10,
name: "Tiktok",
},
];
interface RoleData {
id: number;
label: string;
name: string;
value: string;
levelNumber: number;
}
const FormSchema = z.object({
// level: z.string({
// required_error: "Required",
// }),
fullname: z.string({
required_error: "Required",
}),
username: z.string({
required_error: "Required",
}),
// role: z.string({
// required_error: "Required",
// }),
// nrp: z.string({
// required_error: "Required",
// }),
address: z.string({
required_error: "Required",
}),
email: z.string({
required_error: "Required",
}),
phoneNumber: z.string({
required_error: "Required",
}),
password: z.string({
required_error: "Required",
}),
confirmPassword: z.string({
required_error: "Required",
}),
isValidPassword: z.boolean().refine((val) => val === true, {
message: "Check Password",
}),
sns: z.array(z.string()).optional(),
education: z.string().optional(),
school: z.string().optional(),
competency: z.string().optional(),
});
export default function EditUserForm() {
export default function EditUserPage() {
const router = useRouter();
const params = useParams();
const id = params?.id;
const MySwal = withReactContent(Swal);
const levelName = getCookiesDecrypt("ulnae");
const [roleList, setRoleList] = useState<RoleData[]>([]);
const [userEducations, setUserEducations] = useState<any>();
const [userSchools, setUserSchools] = useState<any>();
const [userCompetencies, setUserCompetencies] = useState<any>();
const params = useSearchParams();
// const userId = params?.id ? Number(params.id) : undefined;
const userIdParam = params.get("id");
const userId = userIdParam ? Number(userIdParam) : undefined;
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
password: "",
confirmPassword: "",
sns: [],
education: "1",
school: "4",
competency: "2",
},
});
const passwordVal = form.watch("password");
const confPasswordVal = form.watch("confirmPassword");
useEffect(() => {
getDataAdditional();
initData();
}, []);
const initData = async () => {
loading();
const levels = await initFetch();
console.log("LEVEL READY:", levels);
const response = await getUserById(String(id));
close();
const list = response?.data?.data;
const user = list?.find((item: any) => item.id === Number(id));
if (!user) {
error("User tidak ditemukan");
return;
}
console.log("RESET WITH USER:", user);
// 3⃣ Reset SETELAH level ADA
form.reset({
fullname: user.fullname ?? "",
username: user.username ?? "",
email: user.email ?? "",
address: user.address ?? "",
phoneNumber: user.phoneNumber ?? "",
// nrp: user.identityNumber ?? "",
// level: String(user.userLevelId),
// role: String(user.userRoleId ?? ""),
password: "",
confirmPassword: "",
sns: [],
education: "",
school: "",
competency: "",
});
const handleSuccess = () => {
router.push("/admin/management-user");
};
// const initData = async () => {
// console.log("PARAM ID:", id);
// loading();
// const response = await getUserById(String(id));
// const res = response?.data?.data;
// close();
// console.log("FULL RESPONSE:", response);
// console.log("RESPONSE.DATA:", response?.data);
// console.log("RESPONSE.DATA.DATA:", response?.data?.data);
// if (Number(res.roleId) > 4) {
// form.setValue("fullname", res?.fullname);
// form.setValue("username", res?.username);
// form.setValue("phoneNumber", res?.phoneNumber);
// form.setValue("nrp", res?.memberIdentity);
// form.setValue("address", res?.address);
// form.setValue("email", res?.email);
// form.setValue("role", res?.role?.code);
// form.setValue("level", String(res?.userLevelId));
// } else {
// initFetch();
// form.setValue("fullname", res?.fullname);
// form.setValue("username", res?.username);
// form.setValue("phoneNumber", res?.phoneNumber);
// form.setValue("nrp", res?.memberIdentity);
// form.setValue("address", res?.address);
// form.setValue("email", res?.email);
// form.setValue("role", res?.role?.code);
// form.setValue("level", String(res?.userLevelId));
// }
// };
const initFetch = async () => {
const response = await getUserLevels();
const res = response?.data?.data ?? [];
const levelsArr: RoleData[] = res.map((levels: any) => ({
id: levels.id,
label: levels.aliasName ?? levels.name,
name: levels.name,
value: String(levels.id),
levelNumber: levels.levelNumber,
}));
setRoleList(levelsArr);
return levelsArr;
const handleCancel = () => {
router.push("/admin/management-user");
};
async function getDataAdditional() {
const resEducations = await getListEducation();
setUserEducations(resEducations?.data?.data);
const resSchools = await getListSchools();
setUserSchools(resSchools?.data?.data);
const resCompetencies = await getListCompetencies();
setUserCompetencies(resCompetencies?.data?.data);
}
const rawRoleId = getCookiesDecrypt("urie");
const userRoleId = rawRoleId ? Number(rawRoleId) : undefined;
if (!userRoleId || Number.isNaN(userRoleId)) {
error("Role user tidak valid, silakan login ulang");
return;
}
async function save(data: z.infer<typeof FormSchema>) {
const req = {
fullname: data.fullname,
username: data.username,
email: data.email,
address: data.address,
phoneNumber: data.phoneNumber,
// userLevelId: Number(data.level),
// userRoleId: userRoleId,
};
// let req: any = {
// id: Number(id),
// firstName: data.fullname,
// username: data.username,
// roleId: data.role,
// userLevelId: Number(data.level),
// memberIdentity: data.nrp,
// address: data.address,
// email: data.email,
// password: data.password,
// passwordConf: data.confirmPassword,
// isDefault: false,
// isAdmin: true,
// };
console.log("USER ROLE ID FROM COOKIE:", rawRoleId, userRoleId);
console.log("REQUEST UPDATE:", req);
loading();
const response = await updateUserInternal(Number(id), req);
if (response?.error) {
error(response.message);
return false;
}
close();
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonColor: "#3085d6",
confirmButtonText: "Oke",
}).then((result) => {
if (result.isConfirmed) {
router.push("/admin/management-user");
}
});
return false;
}
async function onSubmit(data: z.infer<typeof FormSchema>) {
MySwal.fire({
title: "Simpan Data?",
text: "",
icon: "warning",
showCancelButton: true,
cancelButtonColor: "#d33",
confirmButtonColor: "#3085d6",
confirmButtonText: "Simpan",
}).then((result) => {
if (result.isConfirmed) {
save(data);
}
});
if (!userId) {
return (
<div className="container mx-auto py-6">
<div className="text-center">
<p className="text-red-500">User ID tidak valid</p>
<button
onClick={() => router.push("/admin/management-user")}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Kembali ke Management User
</button>
</div>
</div>
);
}
return (
<div>
<SiteBreadcrumb />
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6 bg-white p-10 w-full"
>
<p className="text-xl">Data User</p>
{/* <FormField
control={form.control}
name="level"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Pilih Level</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-[400px] justify-between",
!field.value && "text-muted-foreground",
)}
>
{field.value
? roleList.find((role) => role.value === field.value)
?.label
: "Pilih level"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput />
<CommandList>
<CommandEmpty>No role found.</CommandEmpty>
<CommandGroup>
{roleList.map((role) => (
<CommandItem
value={role.label}
key={role.value}
onSelect={() => {
form.setValue("level", role.value);
}}
>
{role.label}
<Check
className={cn(
"ml-auto",
role.value === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/> */}
<FormField
control={form.control}
name="fullname"
render={({ field }) => (
<FormItem>
<FormLabel>Nama Lengkap</FormLabel>
<FormControl>
<Input
placeholder="Masukkan nama lengkap"
{...field}
className="w-1/2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input
placeholder="Masukkan username"
{...field}
className="w-1/2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* <FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>Role</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="flex flex-wrap gap-3 w-1/2"
>
{roles.map((role) => (
<FormItem
key={role.id}
className="flex items-center space-x-3 space-y-0"
>
<FormControl>
<RadioGroupItem value={role.id} />
</FormControl>
<FormLabel className="font-normal">
{role.name}
</FormLabel>
</FormItem>
))}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
{/* {selectedRole === "OPT-ID" && (
<FormField
control={form.control}
name="sns"
render={() => (
<FormItem>
<div className="mb-4">
<FormLabel>Social Media Yang Ditangani</FormLabel>
</div>
<div className="grid grid-cols-5 gap-2 w-1/2">
{sns.map((item) => (
<FormField
key={item.id}
control={form.control}
name="sns"
render={({ field }) => {
return (
<FormItem
key={item.typeId}
className="flex flex-row items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(
String(item.typeId),
)}
onCheckedChange={(checked) => {
return checked
? field.onChange([
...(field.value || []),
String(item.typeId),
])
: field.onChange(
(field.value || []).filter(
(value) =>
value !== String(item.typeId),
),
);
}}
/>
</FormControl>
<FormLabel className="font-normal">
{item.name}
</FormLabel>
</FormItem>
);
}}
/>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
)}
{selectedRole === "KUR-ID" && (
<>
<FormField
control={form.control}
name="education"
render={({ field }) => (
<FormItem>
<FormLabel>Pendidikan Terakhir</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{userEducations?.map((edu: any) => (
<SelectItem key={edu.id} value={String(edu.id)}>
{edu.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="school"
render={({ field }) => (
<FormItem>
<FormLabel>Universitas / Perguruan Tinggi</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{userSchools?.map((edu: any) => (
<SelectItem key={edu.id} value={String(edu.id)}>
{edu.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="competency"
render={({ field }) => (
<FormItem>
<FormLabel>Kompetensi</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{userCompetencies?.map((edu: any) => (
<SelectItem key={edu.id} value={String(edu.id)}>
{edu.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="nrp"
render={({ field }) => (
<FormItem>
<FormLabel>Nomor Regitrasi Polri {`(NRP)`}</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Masukkan NRP"
{...field}
className="w-1/2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
<FormField
control={form.control}
name="address"
render={({ field }) => (
<FormItem>
<FormLabel>Alamat</FormLabel>
<FormControl>
<Textarea
placeholder="Masukkan alamat"
className="resize-none w-1/2"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="Masukkan email"
{...field}
className="w-1/2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phoneNumber"
render={({ field }) => (
<FormItem>
<FormLabel>No. Handphone</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Masukkan nomor handphone"
{...field}
className="w-1/2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Masukkan kata sandi"
{...field}
className="w-1/2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Konfirmasi Kata Sandi</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Masukkan kata sandi"
{...field}
className="w-1/2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<PasswordChecklist
rules={["minLength", "specialChar", "number", "capital", "match"]}
minLength={8}
value={passwordVal || ""}
valueAgain={confPasswordVal || ""}
onChange={(isValid) => {
form.setValue("isValidPassword", isValid);
}}
className="text-sm"
messages={{
minLength: "Password harus lebih dari 8 karakter",
specialChar: "Password harus memiliki spesial karakter",
number: "Password harus memiliki angka",
capital: "Password harus memiliki huruf kapital",
match: "Password sama",
}}
/>
<Link href="/admin/management-user">
<Button type="button" color="primary" variant="outline">
Back
</Button>
</Link>
<Button type="submit" color="primary" className="mx-3">
Submit
</Button>
</form>
</Form>
<div className="container mx-auto py-6">
<UserForm
id={userId}
mode="edit"
onSuccess={handleSuccess}
onCancel={handleCancel}
/>
</div>
);
}

View File

@ -32,7 +32,7 @@ export default function ManagementUser() {
return (
<div>
<SiteBreadcrumb />
<section className="flex flex-col gap-2 bg-white dark:bg-default-50 rounded-lg p-3 mt-5 border">
<section className="flex flex-col gap-2 bg-slate-50 dark:bg-black rounded-lg p-3 mt-5 border">
<div className="flex justify-between py-3">
<p className="text-lg">
Data User

View File

@ -1,99 +0,0 @@
import * as React from "react";
import { ColumnDef } from "@tanstack/react-table";
import { Eye, MoreVertical, SquarePen, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
formatDateToIndonesian,
getOnlyDate,
htmlToString,
} from "@/utils/globals";
import { Link, useRouter } from "@/i18n/routing";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible";
import StatusToogle from "./status-toogle";
import StaticToogle from "./static-toogle";
const columns: ColumnDef<any>[] = [
// {
// accessorKey: "no",
// header: "No",
// cell: ({ row }) => <span>{row.getValue("no")}</span>,
// },
{
accessorKey: "title",
header: "Judul",
cell: ({ row }) => <span>{row.getValue("title")}</span>,
},
{
accessorKey: "categoryName",
header: "Kategori",
cell: ({ row }) => <span>{row.getValue("categoryName")}</span>,
},
{
accessorKey: "createdAt",
header: "Tanggal Unggah",
cell: ({ row }) => (
<span>{formatDateToIndonesian(row.getValue("createdAt"))}</span>
),
},
{
accessorKey: "statusName",
header: "Status Banner",
cell: ({ row }) => (
<StatusToogle id={row.original.id} initChecked={row.original.isBanner} />
),
},
{
id: "actions",
accessorKey: "action",
header: "Actions",
enableHiding: false,
cell: ({ row }) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
className="bg-transparent ring-offset-transparent hover:bg-transparent hover:ring-0 hover:ring-transparent"
>
<MoreVertical className="h-4 w-4 text-default-800" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="p-0" align="end">
<DropdownMenuItem className="p-2 border-b text-default-700 group focus:bg-default focus:text-primary-foreground rounded-none">
<Link
href={`/contributor/content/image/detail/${row.original.id}`}
>
Detail
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
export default columns;

View File

@ -1,172 +0,0 @@
"use client";
import * as React from "react";
import {
ColumnDef,
ColumnFiltersState,
PaginationState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useSearchParams } from "next/navigation";
import { close, loading } from "@/config/swal";
import { Link, useRouter } from "@/i18n/routing";
import columns from "./banner-column";
import { listBanner } from "@/service/service/settings/settings";
const BannerListTable = () => {
const router = useRouter();
const searchParams = useSearchParams();
const [showData, setShowData] = React.useState("10");
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[],
);
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: Number(showData),
});
const [getData, setGetData] = React.useState<any[]>([]);
const dataChange = searchParams?.get("dataChange");
const [page, setPage] = React.useState(1);
const [totalPage, setTotalPage] = React.useState(1);
const table = useReactTable({
data: getData,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
pagination,
},
});
React.useEffect(() => {
const pageFromUrl = searchParams?.get("page");
if (pageFromUrl) {
setPage(Number(pageFromUrl));
}
}, [searchParams]);
React.useEffect(() => {
if (dataChange) {
router.push("/admin/settings/banner");
}
getListBanner();
}, [dataChange]);
React.useEffect(() => {
getListBanner();
// getListStaticBanner();
}, [page, showData]);
async function getListBanner() {
try {
loading();
const response = await listBanner();
const data = Array.isArray(response?.data?.data?.content)
? response.data.data.content
: [];
console.log("banner", data);
setGetData(data);
} catch (error) {
console.error("Error fetching banner list:", error);
setGetData([]);
} finally {
close();
}
}
// async function getListBanner() {
// loading();
// let temp: any;
// const response = await listBanner();
// const data = response?.data?.data?.content;
// console.log("banner", data);
// setGetData(data);
// close();
// }
return (
<>
<Table className="overflow-hidden mt-10">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-default-200">
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="h-[75px]"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</>
);
};
export default BannerListTable;

View File

@ -1,95 +0,0 @@
import * as React from "react";
import { ColumnDef } from "@tanstack/react-table";
import { Eye, MoreVertical, SquarePen, Trash2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import {
formatDateToIndonesian,
getOnlyDate,
htmlToString,
} from "@/utils/globals";
import { Link, useRouter } from "@/i18n/routing";
import { useToast } from "@/components/ui/use-toast";
import { setBanner } from "@/service/service/settings/settings";
const columns: ColumnDef<any>[] = [
{
accessorKey: "no",
header: "No",
cell: ({ row }) => <span>{row.getValue("no")}</span>,
},
{
accessorKey: "title",
header: "Judul",
cell: ({ row }) => <span>{row.getValue("title")}</span>,
},
{
accessorKey: "categoryName",
header: "Kategori",
cell: ({ row }) => <span>{row.getValue("categoryName")}</span>,
},
{
accessorKey: "createdAt",
header: "Tanggal Unggah",
cell: ({ row }) => (
<span>{formatDateToIndonesian(row.getValue("createdAt"))}</span>
),
},
{
accessorKey: "statusName",
header: "Status",
cell: ({ row }) => <span>{row.getValue("statusName")}</span>,
},
{
id: "actions",
accessorKey: "action",
header: "Actions",
enableHiding: false,
cell: ({ row }) => {
const { toast } = useToast();
const handleBanner = async (id: number) => {
const response = setBanner(id, true);
toast({
title: "Success",
});
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
className="bg-transparent ring-offset-transparent hover:bg-transparent hover:ring-0 hover:ring-transparent"
>
<MoreVertical className="h-4 w-4 text-default-800" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="p-0" align="end">
<DropdownMenuItem className="p-2 border-b text-default-700 group focus:bg-default focus:text-primary-foreground rounded-none">
<Link
href={`/contributor/content/image/detail/${row.original.id}`}
>
Detail
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="p-2 border-b text-default-700 group focus:bg-default focus:text-primary-foreground rounded-none">
<a onClick={() => handleBanner(row.original.id)}>
Jadikan Banner
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
export default columns;

View File

@ -1,40 +0,0 @@
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/components/ui/use-toast";
import { useRouter } from "@/i18n/routing";
import { setStaticBanner } from "@/service/service/settings/settings";
export default function StaticToogle(props: {
id: number;
initChecked: boolean;
}) {
const { id, initChecked } = props;
const { toast } = useToast();
const router = useRouter();
const changeStaticBanner = async (id: number) => {
const response = await setStaticBanner(id);
if (response?.error) {
toast({
variant: "destructive",
title: response?.message,
});
return false;
}
toast({
title: "Success ",
});
router.push("/admin/settings/banner?dataChange=true");
};
return (
<Switch
id="static-toogle"
checked={initChecked}
onCheckedChange={(e) => {
changeStaticBanner(id);
}}
/>
);
}

View File

@ -1,37 +0,0 @@
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/components/ui/use-toast";
import { useRouter } from "@/i18n/routing";
import { setBanner } from "@/service/service/settings/settings";
export default function StatusToogle(props: {
id: number;
initChecked: boolean;
}) {
const { id, initChecked } = props;
const { toast } = useToast();
const router = useRouter();
const disableBanner = async () => {
const response = await setBanner(id, false);
if (response?.error) {
toast({
variant: "destructive",
title: response?.message,
});
return false;
}
toast({
title: "Success ",
});
router.push("/admin/settings/banner?dataChange=true");
};
return (
<Switch
id="status-toogle"
checked={initChecked}
onCheckedChange={() => disableBanner()}
/>
);
}

View File

@ -1,468 +0,0 @@
"use client";
import * as React from "react";
import {
ColumnDef,
ColumnFiltersState,
PaginationState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useRouter, useSearchParams } from "next/navigation";
import { listEnableCategory } from "@/service/content/content";
import { Checkbox } from "@/components/ui/checkbox";
import { close, loading } from "@/config/swal";
import { Link } from "@/i18n/routing";
import { useToast } from "@/components/ui/use-toast";
import CustomPagination from "@/components/table/custom-pagination";
import { listDataMedia } from "@/service/service/broadcast/broadcast";
import { setBanner } from "@/service/service/settings/settings";
const ContentListBanner = () => {
const router = useRouter();
const searchParams = useSearchParams();
const [showData, setShowData] = React.useState("9");
const [categories, setCategories] = React.useState<any>();
const [data, setData] = React.useState<any[]>([]);
const [totalData, setTotalData] = React.useState<number>(1);
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[],
);
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: Number(showData),
});
const [categoryFilter, setCategoryFilter] = React.useState<number[]>([]);
const [statusFilter, setStatusFilter] = React.useState<number[]>([]);
const [selectedItems, setSelectedItems] = React.useState<number[]>([]);
const [page, setPage] = React.useState(1);
const [totalPage, setTotalPage] = React.useState(1);
const [searchQuery, setSearchQuery] = React.useState("");
const [bannerCount, setBannerCount] = React.useState<number>(0);
React.useEffect(() => {
fetchBannerCount();
}, []);
async function fetchBannerCount() {
try {
const res = await listDataMedia(0, "100", "", "", "");
const banners = res?.data?.data?.content?.filter(
(item: any) => item.isBanner,
);
setBannerCount(banners?.length || 0);
setBannerCount(data?.length || 0);
} catch (error) {
console.error("Error fetching banner count:", error);
}
}
const handleKeyUp = () => {
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
};
const handleKeyDown = () => {
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
};
let typingTimer: NodeJS.Timeout;
const doneTypingInterval = 1500;
const handleTyping = () => {
clearTimeout(typingTimer);
typingTimer = setTimeout(() => {
setPage(1);
fetchData();
}, doneTypingInterval);
};
React.useEffect(() => {
fetchData();
}, [categoryFilter, statusFilter]);
async function doneTyping() {
fetchData();
}
React.useEffect(() => {
const pageFromUrl = searchParams?.get("page");
if (pageFromUrl) {
setPage(Number(pageFromUrl));
}
}, [searchParams]);
React.useEffect(() => {
fetchData();
// setPagination({
// pageIndex: 0,
// pageSize: Number(showData),
// });
}, [page, showData]);
async function fetchData() {
try {
loading();
const res = await listDataMedia(
page - 1,
showData,
searchQuery,
categoryFilter?.sort().join(","),
statusFilter?.sort().join(","),
);
const rawData = res?.data?.data;
const contentData = Array.isArray(rawData?.content)
? rawData.content
: [];
contentData.forEach((item: any, index: number) => {
item.no = (page - 1) * Number(showData) + index + 1;
});
setData(contentData);
setTotalData(rawData?.totalElements ?? 0);
setTotalPage(rawData?.totalPages ?? 1);
close();
} catch (error) {
close();
console.error("Error fetching banner data:", error);
setData([]);
setTotalData(0);
setTotalPage(1);
}
}
// async function fetchData() {
// try {
// loading();
// const res = await listDataMedia(
// page - 1,
// showData,
// searchQuery,
// categoryFilter?.sort().join(","),
// statusFilter?.sort().join(",")
// );
// const data = res?.data?.data;
// const contentData = data?.content;
// contentData.forEach((item: any, index: number) => {
// item.no = (page - 1) * Number(showData) + index + 1;
// });
// console.log("contentData : ", data);
// setData(contentData);
// setTotalData(data?.totalElements);
// setTotalPage(data?.totalPages);
// close();
// } catch (error) {
// console.error("Error fetching tasks:", error);
// }
// }
React.useEffect(() => {
getCategories();
}, []);
async function getCategories() {
const category = await listEnableCategory("");
const resCategory = category?.data?.data?.content;
setCategories(resCategory);
}
const handleChange = (type: string, id: number, checked: boolean) => {
if (type === "category") {
if (checked) {
const temp: number[] = [...categoryFilter];
temp.push(id);
setCategoryFilter(temp);
} else {
const temp = categoryFilter.filter((a) => a !== id);
setCategoryFilter(temp);
}
} else {
if (checked) {
const temp: number[] = [...statusFilter];
temp.push(id);
setStatusFilter(temp);
} else {
const temp = statusFilter.filter((a) => a !== id);
setStatusFilter(temp);
}
}
};
const handleSelect = (id: number) => {
setSelectedItems((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id],
);
};
const handleSelectAll = () => {
if (selectedItems.length === data.length) {
setSelectedItems([]);
} else {
setSelectedItems(data.map((item: any) => Number(item.id)));
}
};
const { toast } = useToast();
const handleBanner = async (ids: number[]) => {
try {
// const res = await Promise.all(ids.map((id) => setBanner(id, true)));
for (const element of ids) {
loading();
const res = await setBanner(element, true);
close();
if (res?.error) {
toast({
title: "Gagal",
description:
"Banner sudah melebihi batas maksimum (4 konten). Silahkan di disable banner Lainnya.",
variant: "destructive",
});
} else {
toast({
title: "Sukses",
description: `item berhasil dijadikan banner.`,
});
}
}
} catch (err) {
toast({
title: "Gagal",
description: "Terjadi kesalahan saat menjadikan banner.",
variant: "destructive",
});
}
};
return (
<>
<div className="flex justify-between ">
<Input
type="text"
placeholder="Search"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
handleTyping();
}}
className="max-w-[300px]"
onKeyDown={(e) => {
if (e.key === "Enter") {
fetchData();
}
}}
/>
{/* <div className="flex flex-row gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="md" variant="outline">
1 - {showData} Data
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 text-sm">
<DropdownMenuRadioGroup
value={showData}
onValueChange={setShowData}
>
<DropdownMenuRadioItem value="10">
1 - 10 Data
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="20">
1 - 20 Data
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="25">
1 - 25 Data
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="50">
1 - 50 Data
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Popover>
<PopoverTrigger asChild>
<Button size="md" variant="outline">
Filter
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 ">
<div className="flex flex-col gap-2 px-2">
<div className="flex justify-between text-sm">
<p>Filter</p>
<a
onClick={() => fetchData()}
className="cursor-pointer text-primary"
>
Simpan
</a>
</div>
<div className="flex flex-col gap-1 overflow-auto max-h-[300px] text-xs custom-scrollbar-table">
<p>Kategory</p>
{categories?.map((category: any) => (
<div className="flex items-center space-x-2">
<Checkbox
id={category.id}
checked={categoryFilter.includes(category.id)}
onCheckedChange={(e) =>
handleChange("category", category.id, Boolean(e))
}
/>
<label
htmlFor={category.id}
className="text-xs font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{category.name}
</label>
</div>
))}
<p className="mt-3">Status</p>
<div className="flex items-center space-x-2">
<Checkbox
id="accepted"
checked={statusFilter.includes(1)}
onCheckedChange={(e) =>
handleChange("status", 1, Boolean(e))
}
/>
<label
htmlFor="accepted"
className="text-xs font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Menunggu Review
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="accepted"
checked={statusFilter.includes(2)}
onCheckedChange={(e) =>
handleChange("status", 2, Boolean(e))
}
/>
<label
htmlFor="accepted"
className="text-xs font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Diterima
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="accepted"
checked={statusFilter.includes(3)}
onCheckedChange={(e) =>
handleChange("status", 3, Boolean(e))
}
/>
<label
htmlFor="accepted"
className="text-xs font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Minta Update{" "}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="accepted"
checked={statusFilter.includes(4)}
onCheckedChange={(e) =>
handleChange("status", 4, Boolean(e))
}
/>
<label
htmlFor="accepted"
className="text-xs font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Ditolak
</label>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div> */}
</div>
<div>
{/* Header select all + action */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedItems.length === data.length}
onCheckedChange={handleSelectAll}
/>
<span className="text-black dark:text-white">Pilih Semua</span>
</div>
{selectedItems.length > 0 && (
<Button color="primary" onClick={() => handleBanner(selectedItems)}>
Jadikan Banner
</Button>
)}
</div>
{/* Grid Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{data?.map((item: any) => (
<div
key={item.id}
className="relative rounded-lg shadow-md overflow-hidden border border-gray-200"
>
<div className="absolute top-2 left-2 z-10">
<Checkbox
checked={selectedItems.includes(item.id)}
onCheckedChange={() => handleSelect(item.id)}
/>
</div>
<img
src={item.smallThumbnailLink || "/placeholder.jpg"}
alt={item.title}
className="w-full h-48 object-cover"
/>
<Link
href={`/contributor/content/image/detail/${item?.id}`}
className="p-3"
>
<h4 className="font-semibold text-sm">{item.title}</h4>
</Link>
</div>
))}
</div>
<div className="mt-3">
{data && data?.length > 0 ? (
<CustomPagination
totalPage={totalPage}
onPageChange={(data) => setPage(data)}
/>
) : (
<p>No Data</p>
)}
</div>
</div>
</>
);
};
export default ContentListBanner;

View File

@ -1,56 +0,0 @@
"use client";
import SiteBreadcrumb from "@/components/site-breadcrumb";
import ContentListTable from "./component/table";
import { useState } from "react";
import BannerListTable from "./component/banner-table";
import { Button } from "@/components/ui/button";
import ContentListBanner from "./component/table";
export default function AdminBanner() {
const [selectedTab, setSelectedTab] = useState("content");
return (
<div>
<SiteBreadcrumb />
<div className="w-full overflow-x-auto bg-white dark:bg-black p-4 rounded-sm space-y-3">
<div className="flex justify-between">
{selectedTab === "content" ? "List Media" : " List Banner"}
<div className="flex flex-row gap-1 border-2 rounded-md w-fit mb-5">
<Button
rounded="md"
onClick={() => setSelectedTab("content")}
className={` hover:text-white
${
selectedTab === "content"
? "bg-black text-white "
: "bg-white text-black "
}`}
>
Konten
</Button>
<Button
rounded="md"
onClick={() => setSelectedTab("banner")}
className={` hover:text-white
${
selectedTab === "banner"
? "bg-black text-white "
: "bg-white text-black "
}`}
>
Banner
</Button>
</div>
</div>
{selectedTab === "content" ? (
<ContentListBanner />
) : (
<BannerListTable />
)}
</div>
</div>
);
}

View File

@ -1,974 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PlusIcon, MenuIcon, EditIcon, DeleteIcon } from "@/components/icons";
import {
MasterMenu,
getMasterMenus,
getMasterMenuById,
createMasterMenu,
updateMasterMenu,
deleteMasterMenu,
} from "@/service/menu-modules";
import {
MenuAction,
getMenuActionsByMenuId,
createMenuAction,
updateMenuAction,
deleteMenuAction,
createMenuActionsBatch,
} from "@/service/menu-actions";
import SiteBreadcrumb from "@/components/site-breadcrumb";
import Swal from "sweetalert2";
import { FormField } from "@/components/form/common/FormField";
import { getCookiesDecrypt } from "@/lib/utils";
export default function MenuManagementPage() {
const router = useRouter();
const [menus, setMenus] = useState<MasterMenu[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingMenu, setEditingMenu] = useState<MasterMenu | null>(null);
const [selectedMenuForActions, setSelectedMenuForActions] = useState<MasterMenu | null>(null);
const [menuActions, setMenuActions] = useState<MenuAction[]>([]);
const [isActionsDialogOpen, setIsActionsDialogOpen] = useState(false);
const [isActionFormOpen, setIsActionFormOpen] = useState(false);
const [editingAction, setEditingAction] = useState<MenuAction | null>(null);
const [actionFormData, setActionFormData] = useState({
actionCode: "",
actionName: "",
description: "",
pathUrl: "",
httpMethod: "none",
position: 0,
});
const [formData, setFormData] = useState({
name: "",
description: "",
group: "",
statusId: 1,
parentMenuId: undefined as number | undefined,
icon: "",
});
useEffect(() => {
// Check if user has roleId = 1
const roleId = getCookiesDecrypt("urie");
if (Number(roleId) !== 1) {
Swal.fire({
title: "Access Denied",
text: "You don't have permission to access this page",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
}).then(() => {
router.push("/admin/dashboard");
});
return;
}
loadData();
}, [router]);
const loadData = async () => {
setIsLoading(true);
try {
const menusRes = await getMasterMenus({ limit: 100 });
if (menusRes?.error) {
Swal.fire({
title: "Error",
text: menusRes?.message || "Failed to load menus",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
setMenus([]);
} else {
// Transform snake_case to camelCase for consistency
const menusData = (menusRes?.data?.data || []).map((menu: any) => ({
...menu,
moduleId: menu.module_id || menu.moduleId,
parentMenuId: menu.parent_menu_id !== undefined ? menu.parent_menu_id : menu.parentMenuId,
statusId: menu.status_id || menu.statusId,
isActive: menu.is_active !== undefined ? menu.is_active : menu.isActive,
}));
setMenus(menusData);
}
} catch (error) {
console.error("Error loading data:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred while loading menus",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
setMenus([]);
} finally {
setIsLoading(false);
}
};
const handleOpenDialog = async (menu?: MasterMenu) => {
if (menu) {
// Fetch fresh data from API to ensure all fields are loaded correctly
try {
const res = await getMasterMenuById(menu.id);
if (!res?.error && res?.data?.data) {
const menuData = res.data.data as any;
setEditingMenu(menu);
setFormData({
name: menuData.name || menu.name || "",
description: menuData.description || menu.description || "",
group: menuData.group || menu.group || "",
statusId: menuData.status_id || menuData.statusId || menu.statusId || 1,
parentMenuId: menuData.parent_menu_id !== undefined && menuData.parent_menu_id !== null
? menuData.parent_menu_id
: (menuData.parentMenuId !== undefined && menuData.parentMenuId !== null
? menuData.parentMenuId
: (menu.parentMenuId || undefined)),
icon: menuData.icon || menu.icon || "",
});
} else {
// Fallback to menu object if API call fails
setEditingMenu(menu);
setFormData({
name: menu.name || "",
description: menu.description || "",
group: menu.group || "",
statusId: (menu as any).status_id || menu.statusId || 1,
parentMenuId: (menu as any).parent_menu_id !== undefined && (menu as any).parent_menu_id !== null
? (menu as any).parent_menu_id
: (menu.parentMenuId || undefined),
icon: menu.icon || "",
});
}
} catch (error) {
console.error("Error loading menu details:", error);
// Fallback to menu object if API call fails
setEditingMenu(menu);
setFormData({
name: menu.name || "",
description: menu.description || "",
group: menu.group || "",
statusId: (menu as any).status_id || menu.statusId || 1,
parentMenuId: (menu as any).parent_menu_id !== undefined && (menu as any).parent_menu_id !== null
? (menu as any).parent_menu_id
: (menu.parentMenuId || undefined),
icon: menu.icon || "",
});
}
} else {
setEditingMenu(null);
setFormData({
name: "",
description: "",
group: "",
statusId: 1,
parentMenuId: undefined,
icon: "",
});
}
setIsDialogOpen(true);
};
const handleSave = async () => {
try {
// Prepare payload without moduleId
const payload = {
name: formData.name,
description: formData.description,
group: formData.group,
statusId: formData.statusId,
parentMenuId: formData.parentMenuId,
icon: formData.icon,
};
if (editingMenu) {
const res = await updateMasterMenu(editingMenu.id, payload);
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to update menu",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
} else {
Swal.fire({
title: "Success",
text: "Menu updated successfully",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
await loadData();
setIsDialogOpen(false);
}
} else {
const res = await createMasterMenu(payload);
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to create menu",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
} else {
Swal.fire({
title: "Success",
text: "Menu created successfully",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
await loadData();
setIsDialogOpen(false);
}
}
} catch (error) {
console.error("Error saving menu:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
}
};
const handleManageActions = async (menu: MasterMenu) => {
try {
setSelectedMenuForActions(menu);
setIsActionsDialogOpen(true);
await loadMenuActions(menu.id);
} catch (error) {
console.error("Error opening actions dialog:", error);
Swal.fire({
title: "Error",
text: "Failed to open actions management",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
}
};
const loadMenuActions = async (menuId: number) => {
try {
const res = await getMenuActionsByMenuId(menuId);
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to load menu actions",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
setMenuActions([]);
} else {
setMenuActions(res?.data?.data || []);
}
} catch (error) {
console.error("Error loading menu actions:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred while loading menu actions",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
setMenuActions([]);
}
};
const handleOpenActionForm = (action?: MenuAction) => {
if (action) {
setEditingAction(action);
setActionFormData({
actionCode: action.actionCode,
actionName: action.actionName,
description: action.description || "",
pathUrl: action.pathUrl || "",
httpMethod: action.httpMethod || "none",
position: action.position || 0,
});
} else {
setEditingAction(null);
setActionFormData({
actionCode: "",
actionName: "",
description: "",
pathUrl: "",
httpMethod: "none",
position: menuActions.length + 1,
});
}
setIsActionFormOpen(true);
};
const handleSaveAction = async () => {
if (!selectedMenuForActions) return;
try {
// Prepare payload, converting "none" back to undefined for httpMethod
const payload = {
menuId: selectedMenuForActions.id,
actionCode: actionFormData.actionCode,
actionName: actionFormData.actionName,
description: actionFormData.description || undefined,
pathUrl: actionFormData.pathUrl || undefined,
httpMethod: actionFormData.httpMethod === "none" ? undefined : actionFormData.httpMethod,
position: actionFormData.position,
};
if (editingAction) {
const res = await updateMenuAction(editingAction.id, payload);
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to update action",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
} else {
Swal.fire({
title: "Success",
text: "Action updated successfully",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
await loadMenuActions(selectedMenuForActions.id);
setIsActionFormOpen(false);
}
} else {
const res = await createMenuAction(payload);
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to create action",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
} else {
Swal.fire({
title: "Success",
text: "Action created successfully",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
await loadMenuActions(selectedMenuForActions.id);
setIsActionFormOpen(false);
}
}
} catch (error) {
console.error("Error saving action:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
}
};
const handleDeleteAction = async (action: MenuAction) => {
const result = await Swal.fire({
title: "Delete Action?",
text: `Are you sure you want to delete "${action.actionName}"?`,
icon: "warning",
showCancelButton: true,
confirmButtonText: "Yes, delete it",
cancelButtonText: "Cancel",
customClass: {
popup: 'swal-z-index-9999'
}
});
if (result.isConfirmed) {
try {
const res = await deleteMenuAction(action.id);
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to delete action",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
} else {
Swal.fire({
title: "Deleted!",
text: "Action has been deleted.",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
if (selectedMenuForActions) {
await loadMenuActions(selectedMenuForActions.id);
}
}
} catch (error) {
console.error("Error deleting action:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
}
}
};
const handleQuickAddActions = async () => {
if (!selectedMenuForActions) return;
const standardActions = ["view", "create", "edit", "delete", "approve", "export"];
const existingActionCodes = menuActions.map(a => a.actionCode);
const newActions = standardActions.filter(code => !existingActionCodes.includes(code));
if (newActions.length === 0) {
Swal.fire({
title: "Info",
text: "All standard actions already exist",
icon: "info",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
return;
}
try {
const res = await createMenuActionsBatch({
menuId: selectedMenuForActions.id,
actionCodes: newActions,
});
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to create actions",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
} else {
Swal.fire({
title: "Success",
text: `${newActions.length} actions created successfully`,
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
await loadMenuActions(selectedMenuForActions.id);
}
} catch (error) {
console.error("Error creating actions:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
}
};
const handleDelete = async (menu: MasterMenu) => {
const result = await Swal.fire({
title: "Delete Menu?",
text: `Are you sure you want to delete "${menu.name}"? This action cannot be undone.`,
icon: "warning",
showCancelButton: true,
confirmButtonText: "Yes, delete it",
cancelButtonText: "Cancel",
customClass: {
popup: 'swal-z-index-9999'
}
});
if (result.isConfirmed) {
try {
const res = await deleteMasterMenu(menu.id);
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to delete menu",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
} else {
Swal.fire({
title: "Deleted!",
text: "Menu has been deleted.",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
await loadData();
}
} catch (error) {
console.error("Error deleting menu:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
}
}
};
const roleId = getCookiesDecrypt("urie");
if (Number(roleId) !== 1) {
return null; // Will redirect in useEffect
}
return (
<>
<SiteBreadcrumb />
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Menu Management</h1>
<p className="text-gray-600 mt-2">
Manage system menus and their configurations
</p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2" onClick={() => handleOpenDialog()}>
<PlusIcon className="h-4 w-4" />
Create Menu
</Button>
</DialogTrigger>
{/* @ts-ignore */}
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingMenu ? `Edit Menu: ${editingMenu.name}` : "Create New Menu"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<FormField
label="Menu Name"
name="name"
type="text"
placeholder="e.g., Dashboard, Content Management"
value={formData.name}
onChange={(value) => setFormData({ ...formData, name: value })}
required
/>
<FormField
label="Description"
name="description"
type="text"
placeholder="Brief description of the menu"
value={formData.description}
onChange={(value) => setFormData({ ...formData, description: value })}
required
/>
<FormField
label="Group"
name="group"
type="text"
placeholder="e.g., Main, Settings, Content"
value={formData.group}
onChange={(value) => setFormData({ ...formData, group: value })}
required
/>
<FormField
label="Parent Menu"
name="parentMenuId"
type="select"
placeholder="Select parent menu (optional)"
value={formData.parentMenuId || 0}
onChange={(value) => setFormData({ ...formData, parentMenuId: Number(value) === 0 ? undefined : Number(value) })}
options={[
{ value: 0, label: "No Parent (Root Menu)" },
...menus
.filter((m) => !m.parentMenuId || m.id !== editingMenu?.id)
.map((menu) => ({
value: menu.id,
label: `${menu.name} - ${menu.group}`,
})),
]}
/>
<FormField
label="Icon"
name="icon"
type="text"
placeholder="e.g., material-symbols:dashboard, heroicons:bars-3"
value={formData.icon}
onChange={(value) => setFormData({ ...formData, icon: value })}
helpText="Icon identifier (e.g., from Iconify)"
/>
<FormField
label="Status ID"
name="statusId"
type="number"
placeholder="1"
value={formData.statusId}
onChange={(value) => setFormData({ ...formData, statusId: Number(value) || 1 })}
required
/>
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button onClick={handleSave}>
{editingMenu ? "Update" : "Create"} Menu
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading menus...</p>
</div>
) : menus.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{menus.map((menu) => {
const parentMenu = menus.find((m) => m.id === menu.parentMenuId);
return (
<Card key={menu.id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="truncate">{menu.name}</span>
{menu.isActive ? (
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
Active
</span>
) : (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-800 rounded-full">
Inactive
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 mb-4">
<div className="text-sm text-gray-600">{menu.description}</div>
<div className="text-xs text-gray-500">
<span className="font-medium">Group:</span> {menu.group}
</div>
{parentMenu && (
<div className="text-xs text-gray-500">
<span className="font-medium">Parent:</span> {parentMenu.name}
</div>
)}
{menu.icon && (
<div className="text-xs text-gray-500">
<span className="font-medium">Icon:</span> {menu.icon}
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => handleOpenDialog(menu)}
>
<EditIcon className="h-4 w-4 mr-2" />
Edit
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => handleManageActions(menu)}
>
<MenuIcon className="h-4 w-4 mr-2" />
Actions
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDelete(menu)}
>
<DeleteIcon className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<div className="text-center">
<MenuIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No Menus Found</h3>
<p className="text-gray-500 mb-4">
Create your first menu to define system navigation
</p>
<Button onClick={() => handleOpenDialog()}>
<PlusIcon className="h-4 w-4 mr-2" />
Create Menu
</Button>
</div>
</CardContent>
</Card>
)}
{/* Actions Management Dialog */}
<Dialog open={isActionsDialogOpen} onOpenChange={setIsActionsDialogOpen}>
{/* @ts-ignore */}
<DialogContent size="lg" className="!max-w-[60vw] !w-[60vw] !min-w-[60vw] max-h-[95vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
Manage Actions: {selectedMenuForActions?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">
Manage actions available for this menu
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleQuickAddActions}
>
Quick Add Standard Actions
</Button>
<Button
size="sm"
onClick={() => handleOpenActionForm()}
>
<PlusIcon className="h-4 w-4 mr-2" />
Add Action
</Button>
</div>
</div>
{menuActions.length > 0 ? (
<div className="border rounded-lg">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Code</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Path URL</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Method</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Position</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{menuActions.map((action) => (
<tr key={action.id}>
<td className="px-4 py-3 text-sm font-medium text-gray-900">{action.actionCode}</td>
<td className="px-4 py-3 text-sm text-gray-600">{action.actionName}</td>
<td className="px-4 py-3 text-sm text-gray-600">{action.pathUrl || "-"}</td>
<td className="px-4 py-3 text-sm text-gray-600">{action.httpMethod || "-"}</td>
<td className="px-4 py-3 text-sm text-gray-600">{action.position || "-"}</td>
<td className="px-4 py-3 text-sm">
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenActionForm(action)}
>
<EditIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDeleteAction(action)}
>
<DeleteIcon className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<div className="text-center">
<p className="text-gray-500 mb-4">No actions found for this menu</p>
<Button onClick={() => handleOpenActionForm()}>
<PlusIcon className="h-4 w-4 mr-2" />
Add First Action
</Button>
</div>
</CardContent>
</Card>
)}
</div>
</DialogContent>
</Dialog>
{/* Action Form Dialog */}
<Dialog open={isActionFormOpen} onOpenChange={setIsActionFormOpen}>
{/* @ts-ignore */}
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingAction ? `Edit Action: ${editingAction.actionName}` : "Create New Action"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<FormField
label="Action Code"
name="actionCode"
type="text"
placeholder="e.g., view, create, edit, delete"
value={actionFormData.actionCode}
onChange={(value) => setActionFormData({ ...actionFormData, actionCode: value })}
required
helpText="Unique identifier for the action"
/>
<FormField
label="Action Name"
name="actionName"
type="text"
placeholder="e.g., View Content, Create Content"
value={actionFormData.actionName}
onChange={(value) => setActionFormData({ ...actionFormData, actionName: value })}
required
/>
<FormField
label="Description"
name="description"
type="textarea"
placeholder="Brief description of the action"
value={actionFormData.description}
onChange={(value) => setActionFormData({ ...actionFormData, description: value })}
/>
<FormField
label="Path URL"
name="pathUrl"
type="text"
placeholder="e.g., /admin/articles, /api/articles"
value={actionFormData.pathUrl}
onChange={(value) => setActionFormData({ ...actionFormData, pathUrl: value })}
helpText="Optional: URL path for routing"
/>
<FormField
label="HTTP Method"
name="httpMethod"
type="select"
placeholder="Select HTTP method"
value={actionFormData.httpMethod || "none"}
onChange={(value) => setActionFormData({ ...actionFormData, httpMethod: value })}
options={[
{ value: "none", label: "Not specified" },
{ value: "GET", label: "GET" },
{ value: "POST", label: "POST" },
{ value: "PUT", label: "PUT" },
{ value: "PATCH", label: "PATCH" },
{ value: "DELETE", label: "DELETE" },
]}
/>
<FormField
label="Position"
name="position"
type="number"
placeholder="1"
value={actionFormData.position}
onChange={(value) => setActionFormData({ ...actionFormData, position: Number(value) || 0 })}
helpText="Order of action in the menu"
/>
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<Button
variant="outline"
onClick={() => setIsActionFormOpen(false)}
>
Cancel
</Button>
<Button onClick={handleSaveAction}>
{editingAction ? "Update" : "Create"} Action
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</>
);
}

View File

@ -1,317 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { PlusIcon, SettingsIcon, MenuIcon, ModuleIcon } from "@/components/icons";
import {
MasterMenu,
MasterModule,
MenuModule,
getMasterMenus,
getMasterModules,
getMenuModulesByMenuId,
createMenuModulesBatch,
deleteMenuModule,
} from "@/service/menu-modules";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import SiteBreadcrumb from "@/components/site-breadcrumb";
import Swal from "sweetalert2";
export default function MenuSettingsPage() {
const [menus, setMenus] = useState<MasterMenu[]>([]);
const [modules, setModules] = useState<MasterModule[]>([]);
const [selectedMenu, setSelectedMenu] = useState<MasterMenu | null>(null);
const [menuModules, setMenuModules] = useState<MenuModule[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedModuleIds, setSelectedModuleIds] = useState<number[]>([]);
useEffect(() => {
loadData();
}, []);
useEffect(() => {
if (selectedMenu) {
loadMenuModules(selectedMenu.id);
}
}, [selectedMenu]);
const loadData = async () => {
setIsLoading(true);
try {
const [menusRes, modulesRes] = await Promise.all([
getMasterMenus({ limit: 100 }),
getMasterModules({ limit: 100 }),
]);
if (!menusRes?.error) {
setMenus(menusRes?.data?.data || []);
}
if (!modulesRes?.error) {
setModules(modulesRes?.data?.data || []);
}
} catch (error) {
console.error("Error loading data:", error);
} finally {
setIsLoading(false);
}
};
const loadMenuModules = async (menuId: number) => {
try {
const res = await getMenuModulesByMenuId(menuId);
if (!res?.error) {
setMenuModules(res?.data?.data || []);
setSelectedModuleIds((res?.data?.data || []).map((mm: MenuModule) => mm.moduleId));
}
} catch (error) {
console.error("Error loading menu modules:", error);
}
};
const handleSaveModules = async () => {
if (!selectedMenu) return;
try {
// Delete existing menu modules
for (const menuModule of menuModules) {
await deleteMenuModule(menuModule.id);
}
// Create new menu modules in batch
if (selectedModuleIds.length > 0) {
const res = await createMenuModulesBatch({
menuId: selectedMenu.id,
moduleIds: selectedModuleIds,
});
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to save modules",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
} else {
Swal.fire({
title: "Success",
text: "Modules saved successfully",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
await loadMenuModules(selectedMenu.id);
setIsDialogOpen(false);
}
}
} catch (error) {
console.error("Error saving modules:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
}
};
const toggleModuleSelection = (moduleId: number) => {
setSelectedModuleIds((prev) =>
prev.includes(moduleId)
? prev.filter((id) => id !== moduleId)
: [...prev, moduleId]
);
};
return (
<>
<SiteBreadcrumb />
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Menu Settings</h1>
<p className="text-gray-600 mt-2">
Manage menu and module associations
</p>
</div>
<MenuIcon className="h-6 w-6 text-gray-500" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Menu List */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MenuIcon className="h-5 w-5" />
Menus
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
</div>
) : menus.length > 0 ? (
<div className="space-y-2">
{menus.map((menu) => (
<button
key={menu.id}
onClick={() => setSelectedMenu(menu)}
className={`w-full text-left p-3 rounded-lg border transition-colors ${
selectedMenu?.id === menu.id
? "bg-blue-50 border-blue-500 text-blue-900"
: "bg-white border-gray-200 hover:bg-gray-50"
}`}
>
<div className="font-medium">{menu.name}</div>
<div className="text-sm text-gray-500">{menu.description}</div>
</button>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">
No menus found
</div>
)}
</CardContent>
</Card>
{/* Selected Menu Modules */}
<Card className="lg:col-span-2">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<ModuleIcon className="h-5 w-5" />
{selectedMenu ? `${selectedMenu.name} - Modules` : "Select a Menu"}
</CardTitle>
{selectedMenu && (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<PlusIcon className="h-4 w-4" />
Manage Modules
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Manage Modules for {selectedMenu.name}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
{modules.map((module) => (
<label
key={module.id}
className="flex items-start gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedModuleIds.includes(module.id)}
onChange={() => toggleModuleSelection(module.id)}
className="mt-1"
/>
<div className="flex-1">
<div className="font-medium">{module.name}</div>
<div className="text-sm text-gray-500">{module.description}</div>
<div className="text-xs text-gray-400 mt-1">
{module.pathUrl} {module.actionType}
</div>
</div>
</label>
))}
</div>
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button onClick={handleSaveModules}>
Save Modules
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
</div>
</CardHeader>
<CardContent>
{selectedMenu ? (
menuModules.length > 0 ? (
<div className="space-y-2">
{menuModules.map((menuModule) => (
<div
key={menuModule.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<div className="font-medium">
{menuModule.module?.name || `Module ${menuModule.moduleId}`}
</div>
<div className="text-sm text-gray-500">
{menuModule.module?.description || "No description"}
</div>
</div>
<div className="flex items-center gap-2">
{menuModule.position && (
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
Position: {menuModule.position}
</span>
)}
{menuModule.isActive ? (
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
Active
</span>
) : (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-800 rounded-full">
Inactive
</span>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12 text-gray-500">
<ModuleIcon className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<p>No modules assigned to this menu</p>
<Button
className="mt-4"
onClick={() => setIsDialogOpen(true)}
>
<PlusIcon className="h-4 w-4 mr-2" />
Add Modules
</Button>
</div>
)
) : (
<div className="text-center py-12 text-gray-500">
<MenuIcon className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<p>Select a menu to view its modules</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</>
);
}

View File

@ -1,501 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { PlusIcon, ModuleIcon, EditIcon, DeleteIcon } from "@/components/icons";
import {
MasterModule,
MasterMenu,
getMasterModules,
getMasterMenus,
getMenuModulesByModuleId,
createMasterModule,
updateMasterModule,
deleteMasterModule,
createMenuModulesBatch,
} from "@/service/menu-modules";
import SiteBreadcrumb from "@/components/site-breadcrumb";
import Swal from "sweetalert2";
import { FormField } from "@/components/form/common/FormField";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { getCookiesDecrypt } from "@/lib/utils";
export default function ModuleManagementPage() {
const router = useRouter();
const [modules, setModules] = useState<MasterModule[]>([]);
const [menus, setMenus] = useState<MasterMenu[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingModule, setEditingModule] = useState<MasterModule | null>(null);
const [formData, setFormData] = useState({
name: "",
description: "",
pathUrl: "",
actionType: "",
statusId: 1,
menuIds: [] as number[],
});
useEffect(() => {
// Check if user has roleId = 1
const roleId = getCookiesDecrypt("urie");
if (Number(roleId) !== 1) {
Swal.fire({
title: "Access Denied",
text: "You don't have permission to access this page",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
}).then(() => {
router.push("/admin/dashboard");
});
return;
}
loadData();
}, [router]);
const loadData = async () => {
setIsLoading(true);
try {
const res = await getMasterModules({ limit: 100 });
if (!res?.error) {
setModules(res?.data?.data || []);
}
} catch (error) {
console.error("Error loading modules:", error);
} finally {
setIsLoading(false);
}
};
const loadMenus = async () => {
try {
const res = await getMasterMenus({ limit: 100 });
if (!res?.error) {
setMenus(res?.data?.data || []);
}
} catch (error) {
console.error("Error loading menus:", error);
}
};
const loadModuleMenus = async (moduleId: number) => {
try {
const res = await getMenuModulesByModuleId(moduleId);
if (!res?.error) {
const menuIds = (res?.data?.data || []).map((mm: any) => mm.menu_id || mm.menuId).filter((id: any) => id);
setFormData((prev) => ({ ...prev, menuIds }));
}
} catch (error) {
console.error("Error loading module menus:", error);
}
};
const handleOpenDialog = async (module?: MasterModule) => {
await loadMenus();
if (module) {
setEditingModule(module);
setFormData({
name: module.name,
description: module.description,
pathUrl: module.pathUrl,
actionType: module.actionType || "",
statusId: module.statusId,
menuIds: [],
});
await loadModuleMenus(module.id);
} else {
setEditingModule(null);
setFormData({
name: "",
description: "",
pathUrl: "",
actionType: "",
statusId: 1,
menuIds: [],
});
}
setIsDialogOpen(true);
};
const handleSave = async () => {
try {
const { menuIds, ...moduleData } = formData;
if (editingModule) {
const res = await updateMasterModule(editingModule.id, moduleData);
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to update module",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
return;
}
// Update menu associations
if (menuIds && menuIds.length > 0) {
// Create associations for each selected menu
for (const menuId of menuIds) {
try {
await createMenuModulesBatch({
menuId,
moduleIds: [editingModule.id],
});
} catch (err) {
// Ignore duplicate errors, backend should handle it
console.log("Menu association may already exist:", err);
}
}
}
Swal.fire({
title: "Success",
text: "Module updated successfully",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
await loadData();
setIsDialogOpen(false);
} else {
const res = await createMasterModule(moduleData);
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to create module",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
return;
}
// Get the created module ID from response
const createdModuleId = res?.data?.data?.id || res?.data?.id;
// Create menu associations if menuIds provided
if (createdModuleId && menuIds && menuIds.length > 0) {
for (const menuId of menuIds) {
await createMenuModulesBatch({
menuId,
moduleIds: [createdModuleId],
});
}
}
Swal.fire({
title: "Success",
text: "Module created successfully",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
await loadData();
setIsDialogOpen(false);
}
} catch (error) {
console.error("Error saving module:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
}
};
const handleDelete = async (module: MasterModule) => {
const result = await Swal.fire({
title: "Delete Module?",
text: `Are you sure you want to delete "${module.name}"? This action cannot be undone.`,
icon: "warning",
showCancelButton: true,
confirmButtonText: "Yes, delete it",
cancelButtonText: "Cancel",
customClass: {
popup: 'swal-z-index-9999'
}
});
if (result.isConfirmed) {
try {
const res = await deleteMasterModule(module.id);
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to delete module",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
} else {
Swal.fire({
title: "Deleted!",
text: "Module has been deleted.",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
await loadData();
}
} catch (error) {
console.error("Error deleting module:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
}
}
};
const roleId = getCookiesDecrypt("urie");
if (Number(roleId) !== 1) {
return null; // Will redirect in useEffect
}
return (
<>
<SiteBreadcrumb />
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Module Management</h1>
<p className="text-gray-600 mt-2">
Manage system modules and their configurations
</p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2" onClick={() => handleOpenDialog()}>
<PlusIcon className="h-4 w-4" />
Create Module
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingModule ? `Edit Module: ${editingModule.name}` : "Create New Module"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<FormField
label="Module Name"
name="name"
type="text"
placeholder="e.g., View Articles, Create Content"
value={formData.name}
onChange={(value) => setFormData({ ...formData, name: value })}
required
/>
<FormField
label="Description"
name="description"
type="text"
placeholder="Brief description of the module"
value={formData.description}
onChange={(value) => setFormData({ ...formData, description: value })}
required
/>
<FormField
label="Path URL"
name="pathUrl"
type="text"
placeholder="e.g., /api/articles, /api/content"
value={formData.pathUrl}
onChange={(value) => setFormData({ ...formData, pathUrl: value })}
required
/>
<FormField
label="Action Type"
name="actionType"
type="select"
placeholder="Select action type"
value={formData.actionType}
onChange={(value) => setFormData({ ...formData, actionType: value })}
options={[
{ value: "view", label: "View" },
{ value: "create", label: "Create" },
{ value: "update", label: "Update" },
{ value: "delete", label: "Delete" },
{ value: "approve", label: "Approve" },
{ value: "reject", label: "Reject" },
]}
required
/>
<div className="space-y-2">
<Label htmlFor="menuIds">Menus (Optional)</Label>
<p className="text-sm text-gray-500 mb-2">
Select which menus this module belongs to. A module can belong to multiple menus.
</p>
<div className="border rounded-md p-4 max-h-48 overflow-y-auto">
{menus.length > 0 ? (
<div className="space-y-2">
{menus.map((menu) => (
<div key={menu.id} className="flex items-center space-x-2">
<Checkbox
id={`menu-${menu.id}`}
checked={formData.menuIds.includes(menu.id)}
onCheckedChange={(checked) => {
if (checked) {
setFormData({
...formData,
menuIds: [...formData.menuIds, menu.id],
});
} else {
setFormData({
...formData,
menuIds: formData.menuIds.filter((id) => id !== menu.id),
});
}
}}
/>
<Label
htmlFor={`menu-${menu.id}`}
className="text-sm font-normal cursor-pointer"
>
{menu.name}
</Label>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-500">No menus available</p>
)}
</div>
</div>
<FormField
label="Status ID"
name="statusId"
type="number"
placeholder="1"
value={formData.statusId}
onChange={(value) => setFormData({ ...formData, statusId: Number(value) || 1 })}
required
/>
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button onClick={handleSave}>
{editingModule ? "Update" : "Create"} Module
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading modules...</p>
</div>
) : modules.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{modules.map((module) => (
<Card key={module.id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="truncate">{module.name}</span>
{module.isActive ? (
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
Active
</span>
) : (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-800 rounded-full">
Inactive
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 mb-4">
<div className="text-sm text-gray-600">{module.description}</div>
<div className="text-xs text-gray-500 font-mono bg-gray-50 p-2 rounded">
{module.pathUrl}
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
{module.actionType}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => handleOpenDialog(module)}
>
<EditIcon className="h-4 w-4 mr-2" />
Edit
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDelete(module)}
>
<DeleteIcon className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<div className="text-center">
<ModuleIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No Modules Found</h3>
<p className="text-gray-500 mb-4">
Create your first module to define system capabilities
</p>
<Button onClick={() => handleOpenDialog()}>
<PlusIcon className="h-4 w-4 mr-2" />
Create Module
</Button>
</div>
</CardContent>
</Card>
)}
</div>
</>
);
}

View File

@ -1,395 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { PlusIcon, ModuleIcon, EditIcon, DeleteIcon } from "@/components/icons";
import {
MasterModule,
getMasterModules,
createMasterModule,
updateMasterModule,
deleteMasterModule,
} from "@/service/menu-modules";
import SiteBreadcrumb from "@/components/site-breadcrumb";
import Swal from "sweetalert2";
import { FormField } from "@/components/form/common/FormField";
export default function ModulesSettingsPage() {
const [modules, setModules] = useState<MasterModule[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingModule, setEditingModule] = useState<MasterModule | null>(null);
const [formData, setFormData] = useState({
name: "",
description: "",
pathUrl: "",
actionType: "",
statusId: 1,
});
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
setIsLoading(true);
try {
const res = await getMasterModules({ limit: 100 });
if (!res?.error) {
setModules(res?.data?.data || []);
}
} catch (error) {
console.error("Error loading modules:", error);
} finally {
setIsLoading(false);
}
};
const handleOpenDialog = (module?: MasterModule) => {
if (module) {
setEditingModule(module);
setFormData({
name: module.name ?? "",
description: module.description ?? "",
pathUrl: module.pathUrl ?? "",
actionType: module.actionType ?? "",
statusId: module.statusId ?? 1,
});
} else {
setEditingModule(null);
setFormData({
name: "",
description: "",
pathUrl: "",
actionType: "",
statusId: 1,
});
}
setIsDialogOpen(true);
};
const handleSave = async () => {
try {
if (editingModule) {
const res = await updateMasterModule(editingModule.id, formData);
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to update module",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
});
} else {
Swal.fire({
title: "Success",
text: "Module updated successfully",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
});
await loadData();
setIsDialogOpen(false);
}
} else {
const res = await createMasterModule(formData);
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to create module",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
});
} else {
Swal.fire({
title: "Success",
text: "Module created successfully",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
});
await loadData();
setIsDialogOpen(false);
}
}
} catch (error) {
console.error("Error saving module:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
});
}
};
const handleDelete = async (module: MasterModule) => {
const result = await Swal.fire({
title: "Delete Module?",
text: `Are you sure you want to delete "${module.name}"? This action cannot be undone.`,
icon: "warning",
showCancelButton: true,
confirmButtonText: "Yes, delete it",
cancelButtonText: "Cancel",
customClass: {
popup: "swal-z-index-9999",
},
});
if (result.isConfirmed) {
try {
const res = await deleteMasterModule(module.id);
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to delete module",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
});
} else {
Swal.fire({
title: "Deleted!",
text: "Module has been deleted.",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
});
await loadData();
}
} catch (error) {
console.error("Error deleting module:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
});
}
}
};
return (
<>
<SiteBreadcrumb />
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">
Modules Settings
</h1>
<p className="text-gray-600 mt-2">
Manage system modules and their configurations
</p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
className="flex items-center gap-2"
onClick={() => handleOpenDialog()}
>
<PlusIcon className="h-4 w-4" />
Create Module
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingModule
? `Edit Module: ${editingModule.name}`
: "Create New Module"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<FormField
label="Module Name"
name="name"
type="text"
placeholder="e.g., View Articles, Create Content"
value={formData.name}
onChange={(value) =>
setFormData({ ...formData, name: value })
}
required
/>
<FormField
label="Description"
name="description"
type="text"
placeholder="Brief description of the module"
value={formData.description}
onChange={(value) =>
setFormData({ ...formData, description: value })
}
required
/>
<FormField
label="Path URL"
name="pathUrl"
type="text"
placeholder="e.g., /api/articles, /api/content"
value={formData.pathUrl}
onChange={(value) =>
setFormData({ ...formData, pathUrl: value })
}
required
/>
<FormField
label="Action Type"
name="actionType"
type="select"
placeholder="Select action type"
value={formData.actionType}
onChange={(value) =>
setFormData({ ...formData, actionType: value })
}
options={[
{ value: "view", label: "View" },
{ value: "create", label: "Create" },
{ value: "update", label: "Update" },
{ value: "delete", label: "Delete" },
{ value: "approve", label: "Approve" },
{ value: "reject", label: "Reject" },
]}
required
/>
<FormField
label="Status ID"
name="statusId"
type="number"
placeholder="1"
value={formData.statusId}
onChange={(value) =>
setFormData({ ...formData, statusId: Number(value) || 1 })
}
required
/>
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button onClick={handleSave}>
{editingModule ? "Update" : "Create"} Module
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading modules...</p>
</div>
) : modules.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{modules.map((module) => (
<Card
key={module.id}
className="hover:shadow-lg transition-shadow"
>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="truncate">{module.name}</span>
{module.isActive ? (
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
Active
</span>
) : (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-800 rounded-full">
Inactive
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 mb-4">
<div className="text-sm text-gray-600">
{module.description}
</div>
<div className="text-xs text-gray-500 font-mono bg-gray-50 p-2 rounded">
{module.pathUrl}
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
{module.actionType}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => handleOpenDialog(module)}
>
<EditIcon className="h-4 w-4 mr-2" />
Edit
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDelete(module)}
>
<DeleteIcon className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<div className="text-center">
<ModuleIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No Modules Found
</h3>
<p className="text-gray-500 mb-4">
Create your first module to define system capabilities
</p>
<Button onClick={() => handleOpenDialog()}>
<PlusIcon className="h-4 w-4 mr-2" />
Create Module
</Button>
</div>
</CardContent>
</Card>
)}
</div>
</>
);
}

View File

@ -1,106 +0,0 @@
import * as React from "react";
import { ColumnDef } from "@tanstack/react-table";
import { Eye, MoreVertical, SquarePen, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Link, useRouter } from "@/i18n/routing";
import { useToast } from "@/components/ui/use-toast";
import {
Menubar,
MenubarContent,
MenubarMenu,
MenubarTrigger,
} from "@/components/ui/menubar";
import EditTagModal from "./edit";
const columns: ColumnDef<any>[] = [
{
accessorKey: "no",
header: "No",
cell: ({ row }) => <span>{row.getValue("no")}</span>,
},
{
accessorKey: "tagName",
header: "Nama Tag",
cell: ({ row }) => (
<span className="normal-case">{row.getValue("tagName")}</span>
),
},
{
accessorKey: "categoryName",
header: "Kategori",
cell: ({ row }) => (
<span className="normal-case">{row.getValue("categoryName")}</span>
),
},
{
accessorKey: "contentCount",
header: "Jumlah Content",
cell: ({ row }) => (
<span className="normal-case">
{row.getValue("contentCount") ? row.getValue("contentCount") : "-"}
</span>
),
},
{
id: "actions",
accessorKey: "action",
header: "Actions",
enableHiding: false,
cell: ({ row }) => {
const router = useRouter();
const { toast } = useToast();
const tagDelete = async (id: string) => {
// const response = await deleteDataFAQ(id);
// console.log(response);
// if (response?.error) {
// error(response.message);
// return false;
// }
toast({
title: "Sukses",
description: "Berhasil Delete",
});
router.push("/admin/settings/tag?dataChange=true");
};
return (
<Menubar className="border-none">
<MenubarMenu>
<MenubarTrigger>
<Button
size="icon"
className="bg-transparent ring-offset-transparent hover:bg-transparent hover:ring-0 hover:ring-transparent"
>
<MoreVertical className="h-4 w-4 text-default-800" />
</Button>
</MenubarTrigger>
<MenubarContent className="flex flex-col gap-2 justify-center items-start p-4">
<EditTagModal
id={row.original.id}
isDetail={true}
data={row.original}
/>
<EditTagModal
id={row.original.id}
isDetail={false}
data={row.original}
/>
<a
onClick={() => tagDelete(row.original.id)}
className="hover:underline cursor-pointer hover:text-destructive"
>
Delete
</a>
</MenubarContent>
</MenubarMenu>
</Menubar>
);
},
},
];
export default columns;

View File

@ -1,224 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useRouter } from "@/i18n/routing";
import { Input } from "@/components/ui/input";
import { Fragment, useEffect, useState } from "react";
import { useToast } from "@/components/ui/use-toast";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { getCategoriesAll } from "@/service/service/settings/settings";
import { listArticleCategories } from "@/service/content";
const FormSchema = z.object({
name: z.string({
required_error: "Required",
}),
category: z.string({
required_error: "Required",
}),
});
export default function CreateTagModal() {
const router = useRouter();
const { toast } = useToast();
const t = useTranslations("Menu");
const [categoryList, setCategoryList] = useState<
{ id: number; label: string; value: string }[]
>([]);
const [isOpen, setIsOpen] = useState(false);
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: "",
category: "",
},
});
const onSubmit = async (data: z.infer<typeof FormSchema>) => {
const request = {
tagName: data.name,
categoryId: Number(data.category),
isActive: true,
};
// const response = await postDataFeedback(request);
// close();
// if (response?.error) {
// toast({ title: stringify(response.message), variant: "destructive" });
// return false;
// }
toast({
title: "Succes",
description: "Tag berhasil dibuat",
});
router.push("/admin/settings/tag?dataChange=true");
setIsOpen(false);
};
useEffect(() => {
const fetchCategories = async () => {
try {
const response = await listArticleCategories(1, 100);
if (response?.error) {
console.error("Failed to fetch categories:", response.message);
return;
}
const categories =
response?.data?.data?.map((item: any) => ({
value: String(item.id), // wajib string
label: item.title, // pakai title dari API
})) || [];
setCategoryList(categories);
} catch (error) {
console.error("Failed to fetch categories:", error);
}
};
fetchCategories();
}, []);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button color="primary" size="md">
{t("add-tags", { defaultValue: "Add Tags" })}
</Button>
</DialogTrigger>
<DialogContent size="md">
<DialogHeader>
<DialogTitle>
{" "}
{t("add-tags", { defaultValue: "Add Tags" })}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-3 bg-white dark:bg-default-50 rounded-sm"
>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Pilih Category</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-[400px] justify-between",
!field.value && "text-muted-foreground",
)}
>
{field.value
? categoryList.find(
(categ) => categ.value === field.value,
)?.label
: "Pilih level"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput />
<CommandList>
<CommandEmpty>No role found.</CommandEmpty>
<CommandGroup>
{categoryList.map((role) => (
<CommandItem
value={role.label}
key={role.value}
onSelect={() => {
form.setValue("category", role.value);
}}
>
{role.label}
<Check
className={cn(
"ml-auto",
role.value === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Nama Tag</FormLabel>
<FormControl>
<Input placeholder="Masukkan nama tag" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="submit"
color="primary"
size="md"
className="text-xs"
>
Tambah Tag
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -1,263 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useRouter } from "@/i18n/routing";
import { Input } from "@/components/ui/input";
import { Fragment, useEffect, useState } from "react";
import { close, error, loading } from "@/config/swal";
import { useToast } from "@/components/ui/use-toast";
import { stringify } from "querystring";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { cn } from "@/lib/utils";
import { getCategoriesAll, postDataFeedback } from "@/service/service/settings/settings";
const FormSchema = z.object({
name: z.string({
required_error: "Required",
}),
category: z.string({
required_error: "Required",
}),
// publishTo: z.array(z.string()).refine((value) => value.some((item) => item), {
// message: "Required",
// }),
});
const publishToList = [
{
id: "mabes",
name: "Nasional",
},
{
id: "polda",
name: "Polda",
},
{
id: "satker",
name: "Satker",
},
{
id: "internasional",
name: "Internasional",
},
];
export default function EditTagModal(props: {
id: string;
isDetail: boolean;
data: {
id: number;
tagName: string;
categoryId: number;
subCategoryId: number;
};
}) {
const { id, isDetail, data } = props;
const router = useRouter();
const { toast } = useToast();
const [categoryList, setCategoryList] = useState<
{ id: number; label: string; value: string }[]
>([]);
const [isOpen, setIsOpen] = useState(false);
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
});
useEffect(() => {
initState();
}, [id]);
const initState = async () => {
form.setValue("name", data.tagName);
form.setValue("category", String(data.categoryId));
};
const onSubmit = async (data: z.infer<typeof FormSchema>) => {
const request = {
id: Number(id),
tagName: data.name,
categoryId: Number(data.category),
isActive: true,
};
const response = await postDataFeedback(request);
close();
if (response?.error) {
toast({ title: stringify(response.message), variant: "destructive" });
return false;
}
toast({
title: "Succes",
description: "Tag berhasil diubah",
});
router.push("/admin/settings/tag?dataChange=true");
setIsOpen(false);
};
useEffect(() => {
getCategoryParent();
}, []);
async function getCategoryParent() {
const response = await getCategoriesAll();
const res = response?.data?.data.content;
console.log("res", res);
var levelsArr: { id: number; label: string; value: string }[] = [];
res.forEach((levels: { id: number; name: string }) => {
levelsArr.push({
id: levels.id,
label: levels.name,
value: String(levels.id),
});
});
setCategoryList(levelsArr);
}
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<a className="hover:underline cursor-pointer">
{isDetail ? "Detail" : "Edit"}
</a>
</DialogTrigger>
<DialogContent size="md">
<DialogHeader>
<DialogTitle>{isDetail ? "Detail" : "Edit"} Tag</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-3 bg-white rounded-sm"
>
<FormField
control={form.control}
name="category"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Pilih Category</FormLabel>
<Popover>
<PopoverTrigger asChild disabled={isDetail}>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-[400px] justify-between",
!field.value && "text-muted-foreground"
)}
>
{field.value
? categoryList.find(
(categ) => categ.value === field.value
)?.label
: "Pilih level"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput />
<CommandList>
<CommandEmpty>No role found.</CommandEmpty>
<CommandGroup>
{categoryList.map((role) => (
<CommandItem
value={role.label}
key={role.value}
onSelect={() => {
form.setValue("category", role.value);
}}
>
{role.label}
<Check
className={cn(
"ml-auto",
role.value === field.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Nama Tag</FormLabel>
<FormControl>
<Input
readOnly={isDetail}
placeholder="Masukkan nama tag"
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!isDetail && (
<DialogFooter>
<Button
type="submit"
color="primary"
size="md"
className="text-xs"
>
Edit Tag
</Button>
</DialogFooter>
)}
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -1,178 +0,0 @@
"use client";
import * as React from "react";
import {
ColumnDef,
ColumnFiltersState,
PaginationState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useSearchParams } from "next/navigation";
import columns from "./column";
import { close, loading } from "@/config/swal";
import { Link, useRouter } from "@/i18n/routing";
import CreateFAQModal from "./create";
import { useTranslations } from "next-intl";
import { getTags } from "@/service/content";
const AdminTagTable = () => {
const router = useRouter();
const searchParams = useSearchParams();
const t = useTranslations("Menu");
const dataChange = searchParams?.get("dataChange");
const [openModal, setOpenModal] = React.useState(false);
const [dataTable, setDataTable] = React.useState<any[]>([]);
const [totalData, setTotalData] = React.useState<number>(1);
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[],
);
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [page, setPage] = React.useState(1);
const [totalPage, setTotalPage] = React.useState(1);
const table = useReactTable({
data: dataTable,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
pagination,
},
});
React.useEffect(() => {
if (dataChange) {
router.push("/admin/settings/tag");
}
fetchData();
}, [dataChange]);
React.useEffect(() => {
const pageFromUrl = searchParams?.get("page");
if (pageFromUrl) {
setPage(Number(pageFromUrl));
}
}, [searchParams]);
React.useEffect(() => {
fetchData();
}, [page]);
async function fetchData() {
try {
loading();
const response = await getTags();
const data = Array.isArray(response?.data?.data)
? response.data.data
: [];
data.forEach((item: any, index: number) => {
item.no = (page - 1) * 10 + index + 1;
});
setDataTable(data);
setTotalData(data.length);
setTotalPage(1);
close();
} catch (error) {
close();
console.error("Error fetching tags:", error);
setDataTable([]);
setTotalData(0);
}
}
return (
<div className="w-full overflow-x-auto bg-white dark:bg-default-50 p-4 rounded-sm space-y-3">
<div className="flex justify-between mb-10 items-center">
<p className="text-xl font-medium text-default-900">
{t("tags", { defaultValue: "Tags" })}
</p>
<CreateFAQModal />
</div>
<Table className="overflow-hidden">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-default-200">
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table?.getRowModel()?.rows?.length ? (
table?.getRowModel()?.rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="h-[75px]"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{/* <TablePagination
table={table}
totalData={totalData}
totalPage={totalPage}
/> */}
</div>
);
};
export default AdminTagTable;

View File

@ -1,13 +0,0 @@
"use client";
import SiteBreadcrumb from "@/components/site-breadcrumb";
import AdminTagTable from "./component/table";
export default function TagCategory() {
return (
<>
<SiteBreadcrumb />
<AdminTagTable />
</>
);
}

View File

@ -24,25 +24,6 @@ import {
DialogDescription,
} from "@/components/ui/dialog";
import { getUserLevelDetail } from "@/service/tenant";
import {
getUserLevelMenuAccessesByUserLevelId,
UserLevelMenuAccess,
} from "@/service/user-level-menu-accesses";
import {
getUserLevelMenuActionAccessesByUserLevelIdAndMenuId,
UserLevelMenuActionAccess,
} from "@/service/user-level-menu-action-accesses";
import {
getMenuActionsByMenuId,
MenuAction,
} from "@/service/menu-actions";
import {
getMasterMenus,
MasterMenu,
} from "@/service/menu-modules";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
const useTableColumns = (onEdit?: (data: any) => void) => {
const MySwal = withReactContent(Swal);
@ -211,68 +192,11 @@ const useTableColumns = (onEdit?: (data: any) => void) => {
const [isDialogOpen, setIsDialogOpen] = React.useState(false);
const [detailData, setDetailData] = React.useState<any>(null);
const [menuAccesses, setMenuAccesses] = React.useState<UserLevelMenuAccess[]>([]);
const [actionAccesses, setActionAccesses] = React.useState<Record<number, UserLevelMenuActionAccess[]>>({});
const [menus, setMenus] = React.useState<MasterMenu[]>([]);
const [menuActionsMap, setMenuActionsMap] = React.useState<Record<number, MenuAction[]>>({});
const [isLoadingDetail, setIsLoadingDetail] = React.useState(false);
const handleView = async (id: number) => {
setIsLoadingDetail(true);
try {
// Load basic user level data
const res = await getUserLevelDetail(id);
if (!res?.error) {
setDetailData(res?.data?.data);
// Load menus
const menusRes = await getMasterMenus({ limit: 100 });
if (!menusRes?.error) {
const menusData = (menusRes?.data?.data || []).map((menu: any) => ({
...menu,
moduleId: menu.module_id || menu.moduleId,
parentMenuId: menu.parent_menu_id !== undefined ? menu.parent_menu_id : menu.parentMenuId,
statusId: menu.status_id || menu.statusId,
isActive: menu.is_active !== undefined ? menu.is_active : menu.isActive,
}));
setMenus(menusData);
// Load actions for each menu
const actionsMap: Record<number, MenuAction[]> = {};
for (const menu of menusData) {
try {
const actionsRes = await getMenuActionsByMenuId(menu.id);
if (!actionsRes?.error) {
actionsMap[menu.id] = actionsRes?.data?.data || [];
}
} catch (error) {
console.error(`Error loading actions for menu ${menu.id}:`, error);
}
}
setMenuActionsMap(actionsMap);
}
// Load menu accesses
const menuAccessRes = await getUserLevelMenuAccessesByUserLevelId(id);
if (!menuAccessRes?.error) {
const accesses = menuAccessRes?.data?.data || [];
setMenuAccesses(accesses);
// Load action accesses for each menu
const actionAccessesMap: Record<number, UserLevelMenuActionAccess[]> = {};
for (const menuAccess of accesses.filter((a: UserLevelMenuAccess) => a.canAccess)) {
try {
const actionRes = await getUserLevelMenuActionAccessesByUserLevelIdAndMenuId(id, menuAccess.menuId);
if (!actionRes?.error) {
actionAccessesMap[menuAccess.menuId] = actionRes?.data?.data || [];
}
} catch (error) {
console.error(`Error loading action accesses for menu ${menuAccess.menuId}:`, error);
}
}
setActionAccesses(actionAccessesMap);
}
setIsDialogOpen(true);
} else {
error(res?.message || "Gagal memuat detail user level");
@ -280,8 +204,6 @@ const useTableColumns = (onEdit?: (data: any) => void) => {
} catch (err) {
console.error("View error:", err);
error("Terjadi kesalahan saat memuat data.");
} finally {
setIsLoadingDetail(false);
}
};
@ -396,206 +318,41 @@ const useTableColumns = (onEdit?: (data: any) => void) => {
</DropdownMenuContent>
{/* ✅ Dialog Detail User Level */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent size="md" className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Detail User Level</DialogTitle>
<DialogDescription>
Informasi lengkap dari user level: {detailData?.name || detailData?.aliasName}
Informasi lengkap dari user level ID: {detailData?.id}
</DialogDescription>
</DialogHeader>
{isLoadingDetail ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-2 text-gray-600">Memuat data...</span>
{detailData ? (
<div className="space-y-3 mt-4">
<p>
<span className="font-medium">Name:</span>{" "}
{detailData.aliasName}
</p>
<p>
<span className="font-medium">Group:</span>{" "}
{detailData.group}
</p>
<p>
<span className="font-medium">Parent Level:</span>{" "}
{detailData.parentLevelId || "-"}
</p>
<p>
<span className="font-medium">Created At:</span>{" "}
{detailData.createdAt
? new Date(detailData.createdAt).toLocaleString("id-ID")
: "-"}
</p>
</div>
) : detailData ? (
<Tabs defaultValue="basic" className="w-full mt-4">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger className="border border-black" value="basic">Basic Information</TabsTrigger>
<TabsTrigger value="menus">Menu Access</TabsTrigger>
<TabsTrigger value="actions">Action Access</TabsTrigger>
</TabsList>
{/* Basic Information Tab */}
<TabsContent value="basic" className="space-y-4 mt-4">
<Card>
<CardHeader>
<CardTitle>User Level Information</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-sm font-medium text-gray-600">ID:</span>
<p className="text-base font-mono">{detailData.id}</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">Name:</span>
<p className="text-base">{detailData.name || detailData.aliasName}</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">Alias Name:</span>
<p className="text-base font-mono">{detailData.aliasName}</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">Level Number:</span>
<p className="text-base">{detailData.levelNumber}</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">Group:</span>
<p className="text-base">{detailData.group || "-"}</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">Parent Level ID:</span>
<p className="text-base">{detailData.parentLevelId || "-"}</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">Province ID:</span>
<p className="text-base">{detailData.provinceId || "-"}</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">Is Approval Active:</span>
<Badge className={detailData.isApprovalActive ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"}>
{detailData.isApprovalActive ? "Yes" : "No"}
</Badge>
</div>
<div>
<span className="text-sm font-medium text-gray-600">Is Active:</span>
<Badge className={detailData.isActive ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"}>
{detailData.isActive ? "Active" : "Inactive"}
</Badge>
</div>
<div>
<span className="text-sm font-medium text-gray-600">Created At:</span>
<p className="text-sm">
{detailData.createdAt
? new Date(detailData.createdAt).toLocaleString("id-ID")
: "-"}
</p>
</div>
<div>
<span className="text-sm font-medium text-gray-600">Updated At:</span>
<p className="text-sm">
{detailData.updatedAt
? new Date(detailData.updatedAt).toLocaleString("id-ID")
: "-"}
</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Menu Access Tab */}
<TabsContent value="menus" className="space-y-4 mt-4">
<Card>
<CardHeader>
<CardTitle>Menu Access Configuration</CardTitle>
</CardHeader>
<CardContent>
{menuAccesses.filter((a: UserLevelMenuAccess) => a.canAccess).length > 0 ? (
<div className="space-y-2 max-h-96 overflow-y-auto">
{menuAccesses
.filter((a: UserLevelMenuAccess) => a.canAccess)
.map((access) => {
const menu = menus.find((m) => m.id === access.menuId);
return (
<div
key={access.id}
className="flex items-start gap-3 p-3 border rounded-lg hover:bg-gray-50"
>
<div className="flex-1">
<div className="font-medium">{menu?.name || `Menu ID: ${access.menuId}`}</div>
<div className="text-sm text-gray-500">{menu?.description || "-"}</div>
<div className="text-xs text-gray-400 mt-1">
Group: {menu?.group || "-"} Status: {access.canAccess ? "Accessible" : "No Access"}
</div>
</div>
<Badge className={access.canAccess ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"}>
{access.canAccess ? "Accessible" : "No Access"}
</Badge>
</div>
);
})}
</div>
) : (
<div className="text-center py-8 text-gray-500">
No menu access configured
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Action Access Tab */}
<TabsContent value="actions" className="space-y-4 mt-4">
<Card>
<CardHeader>
<CardTitle>Action Access Configuration</CardTitle>
</CardHeader>
<CardContent>
{Object.keys(actionAccesses).length > 0 ? (
<div className="space-y-4 max-h-96 overflow-y-auto">
{Object.entries(actionAccesses).map(([menuId, actions]) => {
const menu = menus.find((m) => m.id === Number(menuId));
const accessibleActions = actions.filter((a: UserLevelMenuActionAccess) => a.canAccess);
if (accessibleActions.length === 0) return null;
return (
<Card key={menuId} className="border-l-4 border-l-blue-500">
<CardHeader className="pb-3">
<CardTitle className="text-base">{menu?.name || `Menu ID: ${menuId}`}</CardTitle>
<p className="text-sm text-gray-500">{menu?.description || "-"}</p>
</CardHeader>
<CardContent>
<div className="space-y-2">
{accessibleActions.map((actionAccess) => {
const action = menuActionsMap[Number(menuId)]?.find(
(a) => a.actionCode === actionAccess.actionCode
);
return (
<div
key={actionAccess.id}
className="flex items-start gap-3 p-2 border rounded-lg hover:bg-gray-50"
>
<div className="flex-1">
<div className="font-medium text-sm">
{action?.actionName || actionAccess.actionCode}
</div>
<div className="text-xs text-gray-500">
Code: {actionAccess.actionCode}
{action?.pathUrl && ` • Path: ${action.pathUrl}`}
{action?.httpMethod && ` • Method: ${action.httpMethod}`}
</div>
</div>
<Badge className="bg-green-100 text-green-800">
Allowed
</Badge>
</div>
);
})}
</div>
</CardContent>
</Card>
);
})}
</div>
) : (
<div className="text-center py-8 text-gray-500">
No action access configured
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
) : (
<p className="text-gray-500 mt-4">Memuat data...</p>
)}
<div className="flex justify-end mt-5">
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>Tutup</Button>
<Button onClick={() => setIsDialogOpen(false)}>Tutup</Button>
</div>
</DialogContent>
</Dialog>

View File

@ -55,16 +55,13 @@ import {
} from "@tanstack/react-table";
import TablePagination from "@/components/table/table-pagination";
import useTableColumns from "./columns";
import TenantUpdateForm from "@/components/form/tenant/tenant-update-form";
import { errorAutoClose, successAutoClose } from "@/lib/swal";
import { close, loading } from "@/config/swal";
import DetailTenant from "@/components/form/tenant/tenant-detail-update-form";
import { getInfoProfile } from "@/service/auth";
function TenantSettingsContentTable() {
const [activeTab, setActiveTab] = useLocalStorage(
"tenant-settings-active-tab",
"profile",
);
const [activeTab, setActiveTab] = useLocalStorage('tenant-settings-active-tab', 'profile');
const [isUserLevelDialogOpen, setIsUserLevelDialogOpen] = useState(false);
const [workflow, setWorkflow] =
useState<ComprehensiveWorkflowResponse | null>(null);
@ -102,7 +99,7 @@ function TenantSettingsContentTable() {
}
if (!userLevelsRes?.error) {
const data = userLevelsRes?.data?.data ?? [];
const data = userLevelsRes?.data?.data;
data.forEach((item: any, index: number) => {
item.no = (page - 1) * Number(showData) + index + 1;
item.parentLevelName =
@ -124,7 +121,7 @@ function TenantSettingsContentTable() {
};
const handleWorkflowSave = async (
data: CreateApprovalWorkflowWithClientSettingsRequest,
data: CreateApprovalWorkflowWithClientSettingsRequest
) => {
setIsEditingWorkflow(false);
await loadData();
@ -164,7 +161,7 @@ function TenantSettingsContentTable() {
const columns = React.useMemo(
() => useTableColumns((data) => handleEditUserLevel(data)),
[],
[]
);
const [showData, setShowData] = React.useState("10");
const [page, setPage] = React.useState(1);
@ -200,41 +197,24 @@ function TenantSettingsContentTable() {
<div className="container mx-auto p-6 space-y-6 border rounded-lg">
<div className="flex items-center justify-between ">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Tenant Settings
</h1>
<h1 className="text-3xl font-bold text-gray-900">Tenant Settings</h1>
<p className="text-gray-600 mt-2">
Manage approval workflows and user levels for your tenant
</p>
</div>
<div className="flex items-center gap-2">
<SettingsIcon className="h-6 w-6 text-gray-500" />
{/* <Button variant="outline" size="sm" onClick={checkWorkflowStatus}>
<Button variant="outline" size="sm" onClick={checkWorkflowStatus}>
Check Workflow Status
</Button> */}
<Button
variant="outline"
size="sm"
className="bg-red-50 text-red-600 border-red-200 hover:bg-red-100"
onClick={async () => {
const res = await getInfoProfile();
const workflowInfo = res?.data?.data?.approvalWorkflowInfo;
if (workflowInfo) {
showWorkflowModal(workflowInfo);
}
}}
>
Test Modal
</Button>
{/* <Button
<Button
variant="outline"
size="sm"
onClick={() => showWorkflowModal({ hasWorkflowSetup: false })}
className="bg-red-50 text-red-600 border-red-200 hover:bg-red-100"
>
Test Modal
</Button> */}
</Button>
</div>
</div>
@ -274,7 +254,6 @@ function TenantSettingsContentTable() {
</h2>
{workflow && !isEditingWorkflow && (
<Button
variant="outline"
onClick={() => setIsEditingWorkflow(true)}
className="flex items-center gap-2"
>
@ -284,7 +263,6 @@ function TenantSettingsContentTable() {
)}
</div>
{/* {isEditingWorkflow && workflow && workflow.workflow?.id ? ( */}
{isEditingWorkflow ? (
<Card>
<CardHeader>
@ -300,8 +278,6 @@ function TenantSettingsContentTable() {
</CardHeader>
<CardContent>
<ApprovalWorkflowForm
key={workflow?.workflow.id}
workflowId={workflow?.workflow.id}
initialData={
workflow
? {
@ -309,12 +285,8 @@ function TenantSettingsContentTable() {
description: workflow.workflow.description,
isDefault: workflow.workflow.isDefault,
isActive: workflow.workflow.isActive,
requiresApproval:
workflow.clientSettings.requiresApproval,
// workflow.workflow.requiresApproval,
autoPublish:
workflow.clientSettings.autoPublishArticles,
// workflow.workflow.autoPublish,
requiresApproval: workflow.workflow.requiresApproval,
autoPublish: workflow.workflow.autoPublish,
steps:
workflow.steps?.map((step) => ({
stepOrder: step.stepOrder,
@ -381,54 +353,44 @@ function TenantSettingsContentTable() {
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="text-center p-4 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">
{workflow.workflow.totalSteps}
</div>
<div className="text-sm text-gray-600">Total Steps</div>
</div>
<div className="text-center p-4 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">
{workflow.workflow.activeSteps}
</div>
<div className="text-sm text-gray-600">Active Steps</div>
</div>
<div className="text-center p-4 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div
className={`text-2xl font-bold ${
// workflow.workflow.requiresApproval
workflow.clientSettings.requiresApproval
workflow.workflow.requiresApproval
? "text-green-600"
: "text-red-600"
}`}
>
{
// workflow.workflow.requiresApproval
workflow.clientSettings.requiresApproval ? "Yes" : "No"
}
{workflow.workflow.requiresApproval ? "Yes" : "No"}
</div>
<div className="text-sm text-gray-600">
Requires Approval
</div>
</div>
<div className="text-center p-4 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div
className={`text-2xl font-bold ${
// workflow.workflow.autoPublish
workflow.clientSettings.autoPublishArticles
workflow.workflow.autoPublish
? "text-green-600"
: "text-red-600"
}`}
>
{
// workflow.workflow.autoPublish
workflow.clientSettings.autoPublishArticles
? "Yes"
: "No"
}
{workflow.workflow.autoPublish ? "Yes" : "No"}
</div>
<div className="text-sm text-gray-600">Auto Publish</div>
</div>
@ -442,16 +404,14 @@ function TenantSettingsContentTable() {
{workflow.steps.map((step: any, index: number) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-300 rounded-lg"
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-sm font-medium">
{step.stepOrder}
</div>
<div>
<div className="font-medium text-black">
{step.stepName}
</div>
<div className="font-medium">{step.stepName}</div>
<div className="text-sm text-gray-500">
{step.conditionType &&
`Condition: ${step.conditionType}`}
@ -499,7 +459,7 @@ function TenantSettingsContentTable() {
<div className="mb-6">
<h4 className="text-lg font-medium mb-3">Client Settings</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-3 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Default Workflow
</div>
@ -507,7 +467,7 @@ function TenantSettingsContentTable() {
{workflow.clientSettings.defaultWorkflowName}
</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Auto Publish Articles
</div>
@ -523,7 +483,7 @@ function TenantSettingsContentTable() {
: "No"}
</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Requires Approval
</div>
@ -539,7 +499,7 @@ function TenantSettingsContentTable() {
: "No"}
</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Settings Active
</div>
@ -562,7 +522,7 @@ function TenantSettingsContentTable() {
Workflow Statistics
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="p-3 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Total Articles Processed
</div>
@ -570,7 +530,7 @@ function TenantSettingsContentTable() {
{workflow.statistics.totalArticlesProcessed}
</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Pending Articles
</div>
@ -578,7 +538,7 @@ function TenantSettingsContentTable() {
{workflow.statistics.pendingArticles}
</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Approved Articles
</div>
@ -586,7 +546,7 @@ function TenantSettingsContentTable() {
{workflow.statistics.approvedArticles}
</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Rejected Articles
</div>
@ -594,7 +554,7 @@ function TenantSettingsContentTable() {
{workflow.statistics.rejectedArticles}
</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Average Processing Time
</div>
@ -602,7 +562,7 @@ function TenantSettingsContentTable() {
{workflow.statistics.averageProcessingTime}h
</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Most Active Step
</div>
@ -619,7 +579,7 @@ function TenantSettingsContentTable() {
Workflow Information
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-3 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Client ID
</div>
@ -627,7 +587,7 @@ function TenantSettingsContentTable() {
{workflow.workflow.clientId}
</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Created At
</div>
@ -635,7 +595,7 @@ function TenantSettingsContentTable() {
{new Date(workflow.workflow.createdAt).toLocaleString()}
</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Updated At
</div>
@ -643,7 +603,7 @@ function TenantSettingsContentTable() {
{new Date(workflow.workflow.updatedAt).toLocaleString()}
</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Workflow ID
</div>
@ -651,7 +611,7 @@ function TenantSettingsContentTable() {
{workflow.workflow.id}
</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Has Branches
</div>
@ -665,7 +625,7 @@ function TenantSettingsContentTable() {
{workflow.workflow.hasBranches ? "Yes" : "No"}
</div>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-300 rounded-lg">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Max Step Order
</div>
@ -708,10 +668,7 @@ function TenantSettingsContentTable() {
onOpenChange={setIsUserLevelDialogOpen}
>
<DialogTrigger asChild>
<Button
variant="outline"
className="flex items-center gap-2 hover:bg-muted focus-visible:bg-muted"
>
<Button className="flex items-center gap-2">
<PlusIcon className="h-4 w-4" />
Create User Level
</Button>
@ -770,7 +727,7 @@ function TenantSettingsContentTable() {
onOpenChange={setIsHierarchyExpanded}
>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-gray-50 transition-colors">
<CardHeader className="cursor-pointer hover:bg-gray-50 transition-colors">
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<UsersIcon className="h-5 w-5" />
@ -788,7 +745,7 @@ function TenantSettingsContentTable() {
<CardContent>
<div className="space-y-3">
{userLevels
.filter((ul) => !ul.parentLevelId)
.filter((ul) => !ul.parentLevelId) // Root levels
.sort((a, b) => a.levelNumber - b.levelNumber)
.map((rootLevel) => (
<div key={rootLevel.id} className="space-y-2">
@ -798,7 +755,7 @@ function TenantSettingsContentTable() {
{rootLevel.levelNumber}
</div>
<div className="flex-1">
<div className="font-medium text-black">
<div className="font-medium">
{rootLevel.name}
</div>
<div className="text-sm text-gray-500">
@ -833,7 +790,7 @@ function TenantSettingsContentTable() {
{childLevel.levelNumber}
</div>
<div className="flex-1">
<div className="font-medium text-sm text-black">
<div className="font-medium text-sm">
{childLevel.name}
</div>
<div className="text-xs text-gray-500">
@ -865,7 +822,7 @@ function TenantSettingsContentTable() {
)}
<Table className="overflow-hidden mt-3 mx-3">
<TableHeader className="sticky top-0 bg-white dark:bg-default-50 shadow-sm z-10">
<TableHeader className="sticky top-0 bg-white shadow-sm z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="bg-default-200">
{headerGroup.headers.map((header) => (
@ -874,7 +831,7 @@ function TenantSettingsContentTable() {
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
header.getContext()
)}
</TableHead>
))}
@ -893,7 +850,7 @@ function TenantSettingsContentTable() {
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
cell.getContext()
)}
</TableCell>
))}
@ -922,21 +879,19 @@ function TenantSettingsContentTable() {
</DialogHeader>
{editingUserLevel ? (
<UserLevelsForm
mode="single"
<TenantUpdateForm
id={editingUserLevel.id}
initialData={{
id: editingUserLevel.id,
name: editingUserLevel.name,
aliasName: editingUserLevel.aliasName,
levelNumber: editingUserLevel.levelNumber,
parentLevelId: editingUserLevel.parentLevelId || 0,
provinceId: editingUserLevel.provinceId,
provinceId: editingUserLevel.provinceId || 0,
group: editingUserLevel.group || "",
isApprovalActive: editingUserLevel.isApprovalActive,
isActive: editingUserLevel.isActive,
}}
onSave={async (data) => {
// The form handles the update internally
onSuccess={async () => {
setIsEditDialogOpen(false);
setEditingUserLevel(null);
await loadData();

View File

@ -3,21 +3,8 @@ import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
PlusIcon,
SettingsIcon,
UsersIcon,
WorkflowIcon,
DotsIcon,
DeleteIcon,
} from "@/components/icons";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { PlusIcon, SettingsIcon, UsersIcon, WorkflowIcon, DotsIcon, DeleteIcon } from "@/components/icons";
import { ApprovalWorkflowForm } from "@/components/form/ApprovalWorkflowForm";
import { UserLevelsForm } from "@/components/form/UserLevelsForm";
import { useWorkflowModal } from "@/components/modals/WorkflowModalProvider";
@ -55,14 +42,11 @@ import TenantSettingsPageTable from "./component/tenant-settings-content-table";
function TenantSettingsContent() {
const [activeTab, setActiveTab] = useState("workflows");
const [isUserLevelDialogOpen, setIsUserLevelDialogOpen] = useState(false);
const [workflow, setWorkflow] =
useState<ComprehensiveWorkflowResponse | null>(null);
const [workflow, setWorkflow] = useState<ComprehensiveWorkflowResponse | null>(null);
const [userLevels, setUserLevels] = useState<UserLevel[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isEditingWorkflow, setIsEditingWorkflow] = useState(false);
const [editingUserLevel, setEditingUserLevel] = useState<UserLevel | null>(
null,
);
const [editingUserLevel, setEditingUserLevel] = useState<UserLevel | null>(null);
const { checkWorkflowStatus } = useWorkflowStatusCheck();
const { showWorkflowModal } = useWorkflowModal();
@ -75,7 +59,7 @@ function TenantSettingsContent() {
setIsLoading(true);
try {
const [comprehensiveWorkflowRes, userLevelsRes] = await Promise.all([
getApprovalWorkflowComprehensiveDetails(),
getApprovalWorkflowComprehensiveDetails(),
getUserLevels(),
]);
@ -84,7 +68,7 @@ function TenantSettingsContent() {
} else {
setWorkflow(null);
}
if (!userLevelsRes?.error) {
setUserLevels(userLevelsRes?.data?.data || []);
}
@ -95,9 +79,7 @@ function TenantSettingsContent() {
}
};
const handleWorkflowSave = async (
data: CreateApprovalWorkflowWithClientSettingsRequest,
) => {
const handleWorkflowSave = async (data: CreateApprovalWorkflowWithClientSettingsRequest) => {
setIsEditingWorkflow(false);
await loadData(); // Reload data after saving
};
@ -105,7 +87,7 @@ function TenantSettingsContent() {
const handleUserLevelSave = async (data: UserLevelsCreateRequest) => {
try {
const response = await createUserLevel(data);
if (response?.error) {
console.error("Error creating user level:", response?.message);
// You can add error handling here (e.g., show error message)
@ -116,7 +98,7 @@ function TenantSettingsContent() {
} catch (error) {
console.error("Error creating user level:", error);
}
setIsUserLevelDialogOpen(false);
setEditingUserLevel(null);
await loadData(); // Reload data after saving
@ -128,11 +110,7 @@ function TenantSettingsContent() {
};
const handleDeleteUserLevel = async (userLevel: UserLevel) => {
if (
window.confirm(
`Are you sure you want to delete "${userLevel.name}"? This action cannot be undone.`,
)
) {
if (window.confirm(`Are you sure you want to delete "${userLevel.name}"? This action cannot be undone.`)) {
try {
// TODO: Implement delete API call
console.log("Delete user level:", userLevel.id);
@ -159,11 +137,15 @@ function TenantSettingsContent() {
</div>
<div className="flex items-center gap-2">
<SettingsIcon className="h-6 w-6 text-gray-500" />
<Button variant="outline" size="sm" onClick={checkWorkflowStatus}>
<Button
variant="outline"
size="sm"
onClick={checkWorkflowStatus}
>
Check Workflow Status
</Button>
<Button
variant="outline"
<Button
variant="outline"
size="sm"
onClick={() => showWorkflowModal({ hasWorkflowSetup: false })}
className="bg-red-50 text-red-600 border-red-200 hover:bg-red-100"
@ -190,8 +172,7 @@ function TenantSettingsContent() {
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold">Approval Workflow Setup</h2>
{workflow && !isEditingWorkflow && (
<Button
variant="outline"
<Button
onClick={() => setIsEditingWorkflow(true)}
className="flex items-center gap-2"
>
@ -216,47 +197,34 @@ function TenantSettingsContent() {
</CardHeader>
<CardContent>
<ApprovalWorkflowForm
initialData={
workflow
? {
name: workflow.workflow.name,
description: workflow.workflow.description,
isDefault: workflow.workflow.isDefault,
isActive: workflow.workflow.isActive,
requiresApproval: workflow.workflow.requiresApproval,
autoPublish: workflow.workflow.autoPublish,
steps:
workflow.steps?.map((step) => ({
stepOrder: step.stepOrder,
stepName: step.stepName,
requiredUserLevelId: step.requiredUserLevelId,
canSkip: step.canSkip,
autoApproveAfterHours: step.autoApproveAfterHours,
isActive: step.isActive,
conditionType: step.conditionType,
conditionValue: step.conditionValue,
})) || [],
clientApprovalSettings: {
approvalExemptCategories:
workflow.clientSettings.exemptCategoriesDetails ||
[],
approvalExemptRoles:
workflow.clientSettings.exemptRolesDetails || [],
approvalExemptUsers:
workflow.clientSettings.exemptUsersDetails || [],
autoPublishArticles:
workflow.clientSettings.autoPublishArticles,
isActive: workflow.clientSettings.isActive,
requireApprovalFor:
workflow.clientSettings.requireApprovalFor || [],
requiresApproval:
workflow.clientSettings.requiresApproval,
skipApprovalFor:
workflow.clientSettings.skipApprovalFor || [],
},
}
: undefined
}
initialData={workflow ? {
name: workflow.workflow.name,
description: workflow.workflow.description,
isDefault: workflow.workflow.isDefault,
isActive: workflow.workflow.isActive,
requiresApproval: workflow.workflow.requiresApproval,
autoPublish: workflow.workflow.autoPublish,
steps: workflow.steps?.map(step => ({
stepOrder: step.stepOrder,
stepName: step.stepName,
requiredUserLevelId: step.requiredUserLevelId,
canSkip: step.canSkip,
autoApproveAfterHours: step.autoApproveAfterHours,
isActive: step.isActive,
conditionType: step.conditionType,
conditionValue: step.conditionValue,
})) || [],
clientApprovalSettings: {
approvalExemptCategories: workflow.clientSettings.exemptCategoriesDetails || [],
approvalExemptRoles: workflow.clientSettings.exemptRolesDetails || [],
approvalExemptUsers: workflow.clientSettings.exemptUsersDetails || [],
autoPublishArticles: workflow.clientSettings.autoPublishArticles,
isActive: workflow.clientSettings.isActive,
requireApprovalFor: workflow.clientSettings.requireApprovalFor || [],
requiresApproval: workflow.clientSettings.requiresApproval,
skipApprovalFor: workflow.clientSettings.skipApprovalFor || []
}
} : undefined}
workflowId={workflow?.workflow.id}
onSave={handleWorkflowSave}
onCancel={() => setIsEditingWorkflow(false)}
@ -290,38 +258,28 @@ function TenantSettingsContent() {
<p className="text-gray-600 text-sm mb-4">
{workflow.workflow.description}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">
{workflow.workflow.totalSteps}
</div>
<div className="text-2xl font-bold text-blue-600">{workflow.workflow.totalSteps}</div>
<div className="text-sm text-gray-600">Total Steps</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">
{workflow.workflow.activeSteps}
</div>
<div className="text-2xl font-bold text-green-600">{workflow.workflow.activeSteps}</div>
<div className="text-sm text-gray-600">Active Steps</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div
className={`text-2xl font-bold ${workflow.workflow.requiresApproval ? "text-green-600" : "text-red-600"}`}
>
{workflow.workflow.requiresApproval ? "Yes" : "No"}
</div>
<div className="text-sm text-gray-600">
Requires Approval
<div className={`text-2xl font-bold ${workflow.workflow.requiresApproval ? 'text-green-600' : 'text-red-600'}`}>
{workflow.workflow.requiresApproval ? 'Yes' : 'No'}
</div>
<div className="text-sm text-gray-600">Requires Approval</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div
className={`text-2xl font-bold ${workflow.workflow.autoPublish ? "text-green-600" : "text-red-600"}`}
>
{workflow.workflow.autoPublish ? "Yes" : "No"}
<div className={`text-2xl font-bold ${workflow.workflow.autoPublish ? 'text-green-600' : 'text-red-600'}`}>
{workflow.workflow.autoPublish ? 'Yes' : 'No'}
</div>
<div className="text-sm text-gray-600">Auto Publish</div>
</div>
@ -333,10 +291,7 @@ function TenantSettingsContent() {
<h4 className="text-lg font-medium mb-3">Workflow Steps</h4>
<div className="space-y-2">
{workflow.steps.map((step: any, index: number) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-sm font-medium">
{step.stepOrder}
@ -344,12 +299,9 @@ function TenantSettingsContent() {
<div>
<div className="font-medium">{step.stepName}</div>
<div className="text-sm text-gray-500">
{step.conditionType &&
`Condition: ${step.conditionType}`}
{step.autoApproveAfterHours &&
` • Auto-approve after ${step.autoApproveAfterHours}h`}
{step.requiredUserLevelName &&
` • Required Level: ${step.requiredUserLevelName}`}
{step.conditionType && `Condition: ${step.conditionType}`}
{step.autoApproveAfterHours && ` • Auto-approve after ${step.autoApproveAfterHours}h`}
{step.requiredUserLevelName && ` • Required Level: ${step.requiredUserLevelName}`}
</div>
</div>
</div>
@ -391,45 +343,25 @@ function TenantSettingsContent() {
<h4 className="text-lg font-medium mb-3">Client Settings</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Default Workflow
</div>
<div className="text-sm text-gray-600">
{workflow.clientSettings.defaultWorkflowName}
<div className="text-sm font-medium text-gray-700 mb-1">Default Workflow</div>
<div className="text-sm text-gray-600">{workflow.clientSettings.defaultWorkflowName}</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">Auto Publish Articles</div>
<div className={`text-sm font-medium ${workflow.clientSettings.autoPublishArticles ? 'text-green-600' : 'text-red-600'}`}>
{workflow.clientSettings.autoPublishArticles ? 'Yes' : 'No'}
</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Auto Publish Articles
</div>
<div
className={`text-sm font-medium ${workflow.clientSettings.autoPublishArticles ? "text-green-600" : "text-red-600"}`}
>
{workflow.clientSettings.autoPublishArticles
? "Yes"
: "No"}
<div className="text-sm font-medium text-gray-700 mb-1">Requires Approval</div>
<div className={`text-sm font-medium ${workflow.clientSettings.requiresApproval ? 'text-green-600' : 'text-red-600'}`}>
{workflow.clientSettings.requiresApproval ? 'Yes' : 'No'}
</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Requires Approval
</div>
<div
className={`text-sm font-medium ${workflow.clientSettings.requiresApproval ? "text-green-600" : "text-red-600"}`}
>
{workflow.clientSettings.requiresApproval
? "Yes"
: "No"}
</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Settings Active
</div>
<div
className={`text-sm font-medium ${workflow.clientSettings.isActive ? "text-green-600" : "text-red-600"}`}
>
{workflow.clientSettings.isActive ? "Yes" : "No"}
<div className="text-sm font-medium text-gray-700 mb-1">Settings Active</div>
<div className={`text-sm font-medium ${workflow.clientSettings.isActive ? 'text-green-600' : 'text-red-600'}`}>
{workflow.clientSettings.isActive ? 'Yes' : 'No'}
</div>
</div>
</div>
@ -437,116 +369,68 @@ function TenantSettingsContent() {
{/* Statistics */}
<div className="mb-6">
<h4 className="text-lg font-medium mb-3">
Workflow Statistics
</h4>
<h4 className="text-lg font-medium mb-3">Workflow Statistics</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Total Articles Processed
</div>
<div className="text-2xl font-bold text-blue-600">
{workflow.statistics.totalArticlesProcessed}
</div>
<div className="text-sm font-medium text-gray-700 mb-1">Total Articles Processed</div>
<div className="text-2xl font-bold text-blue-600">{workflow.statistics.totalArticlesProcessed}</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Pending Articles
</div>
<div className="text-2xl font-bold text-yellow-600">
{workflow.statistics.pendingArticles}
</div>
<div className="text-sm font-medium text-gray-700 mb-1">Pending Articles</div>
<div className="text-2xl font-bold text-yellow-600">{workflow.statistics.pendingArticles}</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Approved Articles
</div>
<div className="text-2xl font-bold text-green-600">
{workflow.statistics.approvedArticles}
</div>
<div className="text-sm font-medium text-gray-700 mb-1">Approved Articles</div>
<div className="text-2xl font-bold text-green-600">{workflow.statistics.approvedArticles}</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Rejected Articles
</div>
<div className="text-2xl font-bold text-red-600">
{workflow.statistics.rejectedArticles}
</div>
<div className="text-sm font-medium text-gray-700 mb-1">Rejected Articles</div>
<div className="text-2xl font-bold text-red-600">{workflow.statistics.rejectedArticles}</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Average Processing Time
</div>
<div className="text-2xl font-bold text-purple-600">
{workflow.statistics.averageProcessingTime}h
</div>
<div className="text-sm font-medium text-gray-700 mb-1">Average Processing Time</div>
<div className="text-2xl font-bold text-purple-600">{workflow.statistics.averageProcessingTime}h</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Most Active Step
</div>
<div className="text-sm text-gray-600">
{workflow.statistics.mostActiveStep || "N/A"}
</div>
<div className="text-sm font-medium text-gray-700 mb-1">Most Active Step</div>
<div className="text-sm text-gray-600">{workflow.statistics.mostActiveStep || 'N/A'}</div>
</div>
</div>
</div>
{/* Workflow Metadata */}
<div className="mb-6">
<h4 className="text-lg font-medium mb-3">
Workflow Information
</h4>
<h4 className="text-lg font-medium mb-3">Workflow Information</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Client ID
</div>
<div className="text-sm text-gray-600 font-mono">
{workflow.workflow.clientId}
</div>
<div className="text-sm font-medium text-gray-700 mb-1">Client ID</div>
<div className="text-sm text-gray-600 font-mono">{workflow.workflow.clientId}</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Created At
</div>
<div className="text-sm font-medium text-gray-700 mb-1">Created At</div>
<div className="text-sm text-gray-600">
{new Date(workflow.workflow.createdAt).toLocaleString()}
</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Updated At
</div>
<div className="text-sm font-medium text-gray-700 mb-1">Updated At</div>
<div className="text-sm text-gray-600">
{new Date(workflow.workflow.updatedAt).toLocaleString()}
</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Workflow ID
</div>
<div className="text-sm text-gray-600 font-mono">
{workflow.workflow.id}
<div className="text-sm font-medium text-gray-700 mb-1">Workflow ID</div>
<div className="text-sm text-gray-600 font-mono">{workflow.workflow.id}</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">Has Branches</div>
<div className={`text-sm font-medium ${workflow.workflow.hasBranches ? 'text-green-600' : 'text-gray-600'}`}>
{workflow.workflow.hasBranches ? 'Yes' : 'No'}
</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Has Branches
</div>
<div
className={`text-sm font-medium ${workflow.workflow.hasBranches ? "text-green-600" : "text-gray-600"}`}
>
{workflow.workflow.hasBranches ? "Yes" : "No"}
</div>
</div>
<div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-1">
Max Step Order
</div>
<div className="text-sm text-gray-600">
{workflow.workflow.maxStepOrder}
</div>
<div className="text-sm font-medium text-gray-700 mb-1">Max Step Order</div>
<div className="text-sm text-gray-600">{workflow.workflow.maxStepOrder}</div>
</div>
</div>
</div>
@ -557,12 +441,9 @@ function TenantSettingsContent() {
<CardContent className="flex items-center justify-center py-12">
<div className="text-center">
<WorkflowIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No Workflow Configured
</h3>
<h3 className="text-lg font-medium text-gray-900 mb-2">No Workflow Configured</h3>
<p className="text-gray-500 mb-4">
Set up your approval workflow to manage content approval
process
Set up your approval workflow to manage content approval process
</p>
<Button onClick={() => setIsEditingWorkflow(true)}>
<PlusIcon className="h-4 w-4 mr-2" />
@ -578,10 +459,7 @@ function TenantSettingsContent() {
<TabsContent value="user-levels" className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold">User Levels</h2>
<Dialog
open={isUserLevelDialogOpen}
onOpenChange={setIsUserLevelDialogOpen}
>
<Dialog open={isUserLevelDialogOpen} onOpenChange={setIsUserLevelDialogOpen}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<PlusIcon className="h-4 w-4" />
@ -591,28 +469,21 @@ function TenantSettingsContent() {
<DialogContent className="md:max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingUserLevel
? `Edit User Level: ${editingUserLevel.name}`
: "Create New User Level"}
{editingUserLevel ? `Edit User Level: ${editingUserLevel.name}` : "Create New User Level"}
</DialogTitle>
</DialogHeader>
<UserLevelsForm
mode="single"
initialData={
editingUserLevel
? {
// id: editingUserLevel.id,
name: editingUserLevel.name,
aliasName: editingUserLevel.aliasName,
levelNumber: editingUserLevel.levelNumber,
parentLevelId: editingUserLevel.parentLevelId || 0,
provinceId: editingUserLevel.provinceId,
group: editingUserLevel.group || "",
isApprovalActive: editingUserLevel.isApprovalActive,
isActive: editingUserLevel.isActive,
}
: undefined
}
initialData={editingUserLevel ? {
name: editingUserLevel.name,
aliasName: editingUserLevel.aliasName,
levelNumber: editingUserLevel.levelNumber,
parentLevelId: editingUserLevel.parentLevelId || 0,
provinceId: editingUserLevel.provinceId,
group: editingUserLevel.group || "",
isApprovalActive: editingUserLevel.isApprovalActive,
isActive: editingUserLevel.isActive,
} : undefined}
onSave={handleUserLevelSave}
onCancel={() => {
setIsUserLevelDialogOpen(false);
@ -627,29 +498,27 @@ function TenantSettingsContent() {
{userLevels.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">
{userLevels.length}
</div>
<div className="text-2xl font-bold text-blue-600">{userLevels.length}</div>
<div className="text-sm text-gray-600">Total User Levels</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">
{userLevels.filter((ul) => ul.isActive).length}
{userLevels.filter(ul => ul.isActive).length}
</div>
<div className="text-sm text-gray-600">Active Levels</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-purple-600">
{userLevels.filter((ul) => ul.isApprovalActive).length}
{userLevels.filter(ul => ul.isApprovalActive).length}
</div>
<div className="text-sm text-gray-600">Approval Active</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-orange-600">
{userLevels.filter((ul) => ul.parentLevelId).length}
{userLevels.filter(ul => ul.parentLevelId).length}
</div>
<div className="text-sm text-gray-600">Child Levels</div>
</div>
@ -668,9 +537,9 @@ function TenantSettingsContent() {
<CardContent>
<div className="space-y-3">
{userLevels
.filter((ul) => !ul.parentLevelId)
.filter(ul => !ul.parentLevelId) // Root levels
.sort((a, b) => a.levelNumber - b.levelNumber)
.map((rootLevel) => (
.map(rootLevel => (
<div key={rootLevel.id} className="space-y-2">
{/* Root Level */}
<div className="flex items-center gap-3 p-3 bg-blue-50 rounded-lg border-l-4 border-blue-500">
@ -680,8 +549,7 @@ function TenantSettingsContent() {
<div className="flex-1">
<div className="font-medium">{rootLevel.name}</div>
<div className="text-sm text-gray-500">
{rootLevel.aliasName} {" "}
{rootLevel.group || "No group"}
{rootLevel.aliasName} {rootLevel.group || 'No group'}
</div>
</div>
<div className="flex items-center gap-2">
@ -717,26 +585,20 @@ function TenantSettingsContent() {
</Button>
</div>
</div>
{/* Child Levels */}
{userLevels
.filter((ul) => ul.parentLevelId === rootLevel.id)
.filter(ul => ul.parentLevelId === rootLevel.id)
.sort((a, b) => a.levelNumber - b.levelNumber)
.map((childLevel) => (
<div
key={childLevel.id}
className="ml-8 flex items-center gap-3 p-3 bg-gray-50 rounded-lg border-l-4 border-gray-300"
>
.map(childLevel => (
<div key={childLevel.id} className="ml-8 flex items-center gap-3 p-3 bg-gray-50 rounded-lg border-l-4 border-gray-300">
<div className="w-6 h-6 bg-gray-100 text-gray-600 rounded-full flex items-center justify-center text-xs font-medium">
{childLevel.levelNumber}
</div>
<div className="flex-1">
<div className="font-medium text-sm">
{childLevel.name}
</div>
<div className="font-medium text-sm">{childLevel.name}</div>
<div className="text-xs text-gray-500">
{childLevel.aliasName} {" "}
{childLevel.group || "No group"}
{childLevel.aliasName} {childLevel.group || 'No group'}
</div>
</div>
<div className="flex items-center gap-1">
@ -755,9 +617,7 @@ function TenantSettingsContent() {
<Button
variant="outline"
size="sm"
onClick={() =>
handleEditUserLevel(childLevel)
}
onClick={() => handleEditUserLevel(childLevel)}
className="h-7 w-7 p-0 border-gray-300 hover:border-blue-500 hover:bg-blue-50"
title="Edit User Level"
>
@ -766,9 +626,7 @@ function TenantSettingsContent() {
<Button
variant="outline"
size="sm"
onClick={() =>
handleDeleteUserLevel(childLevel)
}
onClick={() => handleDeleteUserLevel(childLevel)}
className="h-7 w-7 p-0 border-gray-300 hover:border-red-500 hover:bg-red-50"
title="Delete User Level"
>
@ -871,9 +729,7 @@ function TenantSettingsContent() {
<CardContent className="flex items-center justify-center py-12">
<div className="text-center">
<UsersIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No User Levels Found
</h3>
<h3 className="text-lg font-medium text-gray-900 mb-2">No User Levels Found</h3>
<p className="text-gray-500 mb-4">
Create your first user level to define approval hierarchy
</p>

View File

@ -1,978 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
ChevronLeftIcon,
EditIcon,
SaveIcon,
SettingsIcon,
UsersIcon,
WorkflowIcon,
} from "@/components/icons";
import {
Tenant,
TenantUpdateRequest,
getTenantById,
updateTenant,
} from "@/service/tenant";
import { getTenantList } from "@/service/tenant";
import SiteBreadcrumb from "@/components/site-breadcrumb";
import Swal from "sweetalert2";
import { FormField } from "@/components/form/common/FormField";
import { getCookiesDecrypt } from "@/lib/utils";
import { ApprovalWorkflowForm } from "@/components/form/ApprovalWorkflowForm";
import { UserLevelsForm } from "@/components/form/UserLevelsForm";
import {
CreateApprovalWorkflowWithClientSettingsRequest,
UserLevelsCreateRequest,
UserLevel,
getUserLevels,
getApprovalWorkflowComprehensiveDetails,
ComprehensiveWorkflowResponse,
createUserLevel,
} from "@/service/approval-workflows";
import TenantCompanyUpdateForm from "@/components/form/tenant/tenant-detail-update-form";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
useReactTable,
} from "@tanstack/react-table";
import TablePagination from "@/components/table/table-pagination";
import useTableColumns from "@/app/[locale]/(admin)/admin/settings/tenant/component/columns";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { PlusIcon, DeleteIcon } from "@/components/icons";
import { errorAutoClose, successAutoClose } from "@/lib/swal";
import { close, loading } from "@/config/swal";
export default function EditTenantPage() {
const router = useRouter();
const params = useParams();
const tenantId = params?.id as string;
const [totalData, setTotalData] = useState<number>(0);
const [totalPage, setTotalPage] = useState<number>(1);
const [tenant, setTenant] = useState<Tenant | null>(null);
const [parentTenants, setParentTenants] = useState<Tenant[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [activeTab, setActiveTab] = useState("profile");
// Workflow state
const [workflow, setWorkflow] =
useState<ComprehensiveWorkflowResponse | null>(null);
const [isEditingWorkflow, setIsEditingWorkflow] = useState(false);
// User Levels state
const [userLevels, setUserLevels] = useState<UserLevel[]>([]);
const [isUserLevelDialogOpen, setIsUserLevelDialogOpen] = useState(false);
const [editingUserLevel, setEditingUserLevel] = useState<UserLevel | null>(
null,
);
// Tenant form data
const [formData, setFormData] = useState<TenantUpdateRequest>({
name: "",
description: "",
clientType: "standalone",
parentClientId: undefined,
maxUsers: undefined,
maxStorage: undefined,
address: "",
phoneNumber: "",
website: "",
isActive: true,
});
useEffect(() => {
// Check if user has roleId = 1
const roleId = getCookiesDecrypt("urie");
if (Number(roleId) !== 1) {
Swal.fire({
title: "Access Denied",
text: "You don't have permission to access this page",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
}).then(() => {
router.push("/admin/dashboard");
});
return;
}
if (tenantId) {
loadData();
}
}, [tenantId, router]);
// Load workflow and user levels when switching to those tabs
useEffect(() => {
if (tenant && (activeTab === "workflows" || activeTab === "user-levels")) {
loadWorkflowAndUserLevels();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab]);
const loadData = async () => {
setIsLoading(true);
try {
const [tenantRes, parentRes] = await Promise.all([
getTenantById(tenantId),
getTenantList({ clientType: "parent_client", limit: 100 }),
]);
if (tenantRes?.error || !tenantRes?.data?.data) {
Swal.fire({
title: "Error",
text: tenantRes?.message || "Failed to load tenant data",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
}).then(() => {
router.push("/admin/tenants");
});
return;
}
const tenantData = tenantRes.data.data;
setTenant(tenantData);
// Set form data with all available fields
setFormData({
name: tenantData.name || "",
description: tenantData.description || "",
clientType: tenantData.clientType || "standalone",
parentClientId: tenantData.parentClientId || undefined,
maxUsers: tenantData.maxUsers || undefined,
maxStorage: tenantData.maxStorage || undefined,
address: tenantData.address || "",
phoneNumber: tenantData.phoneNumber || "",
website: tenantData.website || "",
isActive:
tenantData.isActive !== undefined ? tenantData.isActive : true,
logoUrl: tenantData.logoUrl || undefined,
logoImagePath: tenantData.logoImagePath || undefined,
});
if (!parentRes?.error) {
setParentTenants(parentRes?.data?.data || []);
}
// Load workflow and user levels if on those tabs
// Note: This will be loaded when tab changes via useEffect
} catch (error) {
console.error("Error loading tenant:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred while loading tenant data",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
}).then(() => {
router.push("/admin/tenants");
});
} finally {
setIsLoading(false);
}
};
const loadWorkflowAndUserLevels = async () => {
try {
const [comprehensiveWorkflowRes, userLevelsRes] = await Promise.all([
getApprovalWorkflowComprehensiveDetails(),
getUserLevels(),
]);
if (!comprehensiveWorkflowRes?.error) {
setWorkflow(comprehensiveWorkflowRes?.data?.data || null);
} else {
setWorkflow(null);
}
if (!userLevelsRes?.error) {
const data = userLevelsRes?.data?.data || [];
setUserLevels(data);
setTotalData(data.length);
const pageSize = pagination.pageSize;
setTotalPage(Math.max(1, Math.ceil(data.length / pageSize)));
}
if (!userLevelsRes?.error) {
setUserLevels(userLevelsRes?.data?.data || []);
}
} catch (error) {
console.error("Error loading workflow and user levels:", error);
}
};
const handleSaveTenantInfo = async () => {
if (!tenantId) return;
setIsSaving(true);
try {
const updateData: TenantUpdateRequest = {
name: formData.name,
description: formData.description || undefined,
clientType: formData.clientType,
parentClientId: formData.parentClientId || undefined,
maxUsers: formData.maxUsers,
maxStorage: formData.maxStorage,
address: formData.address || undefined,
phoneNumber: formData.phoneNumber || undefined,
website: formData.website || undefined,
isActive: formData.isActive,
};
const res = await updateTenant(tenantId, updateData);
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to update tenant",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
});
} else {
Swal.fire({
title: "Success",
text: "Tenant updated successfully",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
});
await loadData();
}
} catch (error) {
console.error("Error saving tenant:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
});
} finally {
setIsSaving(false);
}
};
const handleWorkflowSave = async (
data: CreateApprovalWorkflowWithClientSettingsRequest,
) => {
setIsEditingWorkflow(false);
await loadWorkflowAndUserLevels();
};
const handleUserLevelSave = async (data: UserLevelsCreateRequest) => {
try {
loading();
const response = await createUserLevel(data);
close();
if (response?.error) {
errorAutoClose(response.message || "Failed to create user level.");
return;
}
successAutoClose("User level created successfully.");
setIsUserLevelDialogOpen(false);
setEditingUserLevel(null);
setTimeout(async () => {
await loadWorkflowAndUserLevels();
}, 1000);
} catch (error) {
close();
errorAutoClose("An error occurred while creating user level.");
console.error("Error creating user level:", error);
}
};
const handleEditUserLevel = (userLevel: UserLevel) => {
setEditingUserLevel(userLevel);
setIsUserLevelDialogOpen(true);
};
const handleDeleteUserLevel = async (userLevel: UserLevel) => {
const result = await Swal.fire({
title: "Delete User Level?",
text: `Are you sure you want to delete "${userLevel.name}"? This action cannot be undone.`,
icon: "warning",
showCancelButton: true,
confirmButtonText: "Yes, delete it",
cancelButtonText: "Cancel",
customClass: {
popup: "swal-z-index-9999",
},
});
if (result.isConfirmed) {
try {
// TODO: Implement delete API call
console.log("Delete user level:", userLevel.id);
await loadWorkflowAndUserLevels();
} catch (error) {
console.error("Error deleting user level:", error);
}
}
};
const columns = React.useMemo(
() => useTableColumns((data) => handleEditUserLevel(data)),
[],
);
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const table = useReactTable({
data: userLevels,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onPaginationChange: setPagination,
state: {
pagination,
},
});
const roleId = getCookiesDecrypt("urie");
if (Number(roleId) !== 1) {
return null;
}
if (isLoading) {
return (
<>
<SiteBreadcrumb />
<div className="container mx-auto p-6">
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading tenant data...</p>
</div>
</div>
</>
);
}
if (!tenant) {
return null;
}
return (
<>
<SiteBreadcrumb />
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={() => router.push("/admin/tenants")}
>
<ChevronLeftIcon className="h-4 w-4 mr-2" />
Back
</Button>
<div>
<h1 className="text-3xl font-bold text-gray-900">
Edit Tenant: {tenant.name}
</h1>
<p className="text-gray-600 mt-2">
Manage tenant information, workflows, and user levels
</p>
</div>
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="profile" className="flex items-center gap-2">
<SettingsIcon className="h-4 w-4" />
Tenant Information
</TabsTrigger>
<TabsTrigger value="workflows" className="flex items-center gap-2">
<WorkflowIcon className="h-4 w-4" />
Approval Workflows
</TabsTrigger>
<TabsTrigger
value="user-levels"
className="flex items-center gap-2"
>
<UsersIcon className="h-4 w-4" />
User Levels
</TabsTrigger>
</TabsList>
{/* Tenant Information Tab */}
<TabsContent value="profile" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Tenant Information</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<FormField
label="Tenant Name"
name="name"
type="text"
placeholder="e.g., Company ABC, Organization XYZ"
value={formData.name}
onChange={(value) =>
setFormData({ ...formData, name: value })
}
required
/>
<FormField
label="Description"
name="description"
type="textarea"
placeholder="Brief description of the tenant"
value={formData.description || ""}
onChange={(value) =>
setFormData({
...formData,
description: value || undefined,
})
}
/>
<FormField
label="Client Type"
name="clientType"
type="select"
placeholder="Select client type"
value={formData.clientType}
onChange={(value) =>
setFormData({ ...formData, clientType: value as any })
}
options={[
{ value: "standalone", label: "Standalone" },
{ value: "parent_client", label: "Parent Client" },
{ value: "sub_client", label: "Sub Client" },
]}
required
/>
{formData.clientType === "sub_client" && (
<FormField
label="Parent Tenant"
name="parentClientId"
type="select"
placeholder="Select parent tenant"
value={formData.parentClientId || "none"}
onChange={(value) =>
setFormData({
...formData,
parentClientId: value === "none" ? undefined : value,
})
}
options={[
{ value: "none", label: "No Parent Tenant" },
...parentTenants
.filter((t) => t.id !== tenantId)
.map((t) => ({
value: t.id,
label: t.name,
})),
]}
required
/>
)}
<div className="grid grid-cols-2 gap-4">
<FormField
label="Max Users"
name="maxUsers"
type="number"
placeholder="e.g., 100"
value={formData.maxUsers?.toString() || ""}
onChange={(value) =>
setFormData({
...formData,
maxUsers: value ? Number(value) : undefined,
})
}
helpText="Maximum number of users allowed"
/>
<FormField
label="Max Storage (MB)"
name="maxStorage"
type="number"
placeholder="e.g., 10000"
value={formData.maxStorage?.toString() || ""}
onChange={(value) =>
setFormData({
...formData,
maxStorage: value ? Number(value) : undefined,
})
}
helpText="Maximum storage in MB"
/>
</div>
<FormField
label="Address"
name="address"
type="textarea"
placeholder="Tenant address"
value={formData.address || ""}
onChange={(value) =>
setFormData({ ...formData, address: value || undefined })
}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
label="Phone Number"
name="phoneNumber"
type="tel"
placeholder="e.g., +62 123 456 7890"
value={formData.phoneNumber || ""}
onChange={(value) =>
setFormData({
...formData,
phoneNumber: value || undefined,
})
}
/>
<FormField
label="Website"
name="website"
type="url"
placeholder="e.g., https://example.com"
value={formData.website || ""}
onChange={(value) =>
setFormData({ ...formData, website: value || undefined })
}
/>
</div>
<FormField
label="Status"
name="isActive"
type="select"
placeholder="Select status"
value={formData.isActive ? "active" : "inactive"}
onChange={(value) =>
setFormData({ ...formData, isActive: value === "active" })
}
options={[
{ value: "active", label: "Active" },
{ value: "inactive", label: "Inactive" },
]}
required
/>
<div className="flex justify-end pt-4 border-t">
<Button
onClick={handleSaveTenantInfo}
disabled={isSaving}
className="flex items-center gap-2"
>
<SaveIcon className="h-4 w-4" />
{isSaving ? "Saving..." : "Save Tenant Information"}
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Approval Workflows Tab */}
<TabsContent value="workflows" className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold">
Approval Workflow Setup
</h2>
{workflow && !isEditingWorkflow && (
<Button variant="outline"
onClick={() => setIsEditingWorkflow(true)}
className="flex items-center gap-2"
>
<SettingsIcon className="h-4 w-4" />
Edit Workflow
</Button>
)}
</div>
{isEditingWorkflow ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Setup Approval Workflow</span>
<Button
variant="outline"
onClick={() => setIsEditingWorkflow(false)}
>
Cancel
</Button>
</CardTitle>
</CardHeader>
<CardContent>
<ApprovalWorkflowForm
initialData={
workflow
? {
name: workflow.workflow.name,
description: workflow.workflow.description,
isDefault: workflow.workflow.isDefault,
isActive: workflow.workflow.isActive,
requiresApproval:
workflow.workflow.requiresApproval,
autoPublish: workflow.workflow.autoPublish,
steps:
workflow.steps?.map((step) => ({
stepOrder: step.stepOrder,
stepName: step.stepName,
requiredUserLevelId: step.requiredUserLevelId,
canSkip: step.canSkip,
autoApproveAfterHours:
step.autoApproveAfterHours,
isActive: step.isActive,
conditionType: step.conditionType,
conditionValue: step.conditionValue,
})) || [],
clientApprovalSettings: {
approvalExemptCategories:
workflow.clientSettings
.exemptCategoriesDetails || [],
approvalExemptRoles:
workflow.clientSettings.exemptRolesDetails ||
[],
approvalExemptUsers:
workflow.clientSettings.exemptUsersDetails ||
[],
autoPublishArticles:
workflow.clientSettings.autoPublishArticles,
isActive: workflow.clientSettings.isActive,
requireApprovalFor:
workflow.clientSettings.requireApprovalFor ||
[],
requiresApproval:
workflow.clientSettings.requiresApproval,
skipApprovalFor:
workflow.clientSettings.skipApprovalFor || [],
},
}
: undefined
}
workflowId={workflow?.workflow.id}
onSave={handleWorkflowSave}
onCancel={() => setIsEditingWorkflow(false)}
/>
</CardContent>
</Card>
) : workflow ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{workflow.workflow.name}</span>
<div className="flex items-center gap-2">
{workflow.workflow.isDefault && (
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded-full">
Default
</span>
)}
{workflow.workflow.isActive ? (
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
Active
</span>
) : (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-800 rounded-full">
Inactive
</span>
)}
</div>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 text-sm mb-4">
{workflow.workflow.description}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-blue-600">
{workflow.workflow.totalSteps}
</div>
<div className="text-sm text-gray-600">Total Steps</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">
{workflow.workflow.activeSteps}
</div>
<div className="text-sm text-gray-600">Active Steps</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div
className={`text-2xl font-bold ${workflow.workflow.requiresApproval ? "text-green-600" : "text-red-600"}`}
>
{workflow.workflow.requiresApproval ? "Yes" : "No"}
</div>
<div className="text-sm text-gray-600">
Requires Approval
</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<div
className={`text-2xl font-bold ${workflow.workflow.autoPublish ? "text-green-600" : "text-red-600"}`}
>
{workflow.workflow.autoPublish ? "Yes" : "No"}
</div>
<div className="text-sm text-gray-600">Auto Publish</div>
</div>
</div>
{workflow.steps && workflow.steps.length > 0 && (
<div>
<h4 className="text-lg font-medium mb-3">
Workflow Steps
</h4>
<div className="space-y-2">
{workflow.steps.map((step: any, index: number) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-sm font-medium">
{step.stepOrder}
</div>
<div>
<div className="font-medium">
{step.stepName}
</div>
<div className="text-sm text-gray-600">
Required Level: {step.requiredUserLevelName}
</div>
</div>
</div>
{step.isActive ? (
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
Active
</span>
) : (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-800 rounded-full">
Inactive
</span>
)}
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<div className="text-center">
<WorkflowIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No Workflow Found
</h3>
<p className="text-gray-500 mb-4">
No approval workflow has been set up for this tenant yet.
</p>
<Button onClick={() => setIsEditingWorkflow(true)}>
<PlusIcon className="h-4 w-4 mr-2" />
Create Workflow
</Button>
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* User Levels Tab */}
<TabsContent value="user-levels" className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold">User Levels Management</h2>
<Dialog
open={isUserLevelDialogOpen}
onOpenChange={setIsUserLevelDialogOpen}
>
<DialogTrigger asChild>
<Button
className="flex items-center gap-2"
onClick={() => {
setEditingUserLevel(null);
setIsUserLevelDialogOpen(true);
}}
>
<PlusIcon className="h-4 w-4" />
Create User Level
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingUserLevel
? `Edit User Level: ${editingUserLevel.name}`
: "Create New User Level"}
</DialogTitle>
</DialogHeader>
<UserLevelsForm
mode="single"
initialData={
editingUserLevel
? {
// id: editingUserLevel.id,
name: editingUserLevel.name,
aliasName: editingUserLevel.aliasName,
levelNumber: editingUserLevel.levelNumber,
parentLevelId: editingUserLevel.parentLevelId || 0,
provinceId: editingUserLevel.provinceId,
group: editingUserLevel.group || "",
isApprovalActive: editingUserLevel.isApprovalActive,
isActive: editingUserLevel.isActive,
}
: undefined
}
onSave={handleUserLevelSave}
onCancel={() => {
setIsUserLevelDialogOpen(false);
setEditingUserLevel(null);
}}
/>
</DialogContent>
</Dialog>
</div>
{userLevels.length > 0 ? (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
{table
.getHeaderGroups()
.map((headerGroup) =>
headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
)),
)}
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
handleEditUserLevel(row.original)
}
>
<EditIcon className="h-4 w-4 mr-2" />
Edit
</Button>
<Button
variant="outline"
size="sm"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() =>
handleDeleteUserLevel(row.original)
}
>
<DeleteIcon className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length + 1}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="p-4">
<TablePagination
table={table}
totalData={totalData}
totalPage={totalPage}
/>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<div className="text-center">
<UsersIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No User Levels Found
</h3>
<p className="text-gray-500 mb-4">
Create your first user level to define user hierarchy
</p>
<Button
onClick={() => {
setEditingUserLevel(null);
setIsUserLevelDialogOpen(true);
}}
>
<PlusIcon className="h-4 w-4 mr-2" />
Create User Level
</Button>
</div>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
</>
);
}

View File

@ -1,463 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { PlusIcon, EditIcon, DeleteIcon } from "@/components/icons";
import {
Tenant,
TenantCreateRequest,
getTenantList,
createTenant,
deleteTenant,
} from "@/service/tenant";
import SiteBreadcrumb from "@/components/site-breadcrumb";
import Swal from "sweetalert2";
import { FormField } from "@/components/form/common/FormField";
import { getCookiesDecrypt } from "@/lib/utils";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export default function TenantsManagementPage() {
const router = useRouter();
const [tenants, setTenants] = useState<Tenant[]>([]);
const [parentTenants, setParentTenants] = useState<Tenant[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [formData, setFormData] = useState<TenantCreateRequest>({
name: "",
description: "",
clientType: "standalone",
parentClientId: undefined,
maxUsers: undefined,
maxStorage: undefined,
address: "",
phoneNumber: "",
website: "",
});
useEffect(() => {
// Check if user has roleId = 1
const roleId = getCookiesDecrypt("urie");
if (Number(roleId) !== 1) {
Swal.fire({
title: "Access Denied",
text: "You don't have permission to access this page",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
}).then(() => {
router.push("/admin/dashboard");
});
return;
}
loadData();
}, [router]);
const loadData = async () => {
setIsLoading(true);
try {
const [tenantsRes, parentRes] = await Promise.all([
getTenantList({ limit: 100 }),
getTenantList({ clientType: "parent_client", limit: 100 }),
]);
if (!tenantsRes?.error) {
setTenants(tenantsRes?.data?.data || []);
}
if (!parentRes?.error) {
setParentTenants(parentRes?.data?.data || []);
}
} catch (error) {
console.error("Error loading tenants:", error);
} finally {
setIsLoading(false);
}
};
const handleOpenDialog = () => {
setFormData({
name: "",
description: "",
clientType: "standalone",
parentClientId: undefined,
maxUsers: undefined,
maxStorage: undefined,
address: "",
phoneNumber: "",
website: "",
});
setIsDialogOpen(true);
};
const handleSave = async () => {
try {
const createData: TenantCreateRequest = {
name: formData.name,
description: formData.description || undefined,
clientType: formData.clientType,
parentClientId: formData.parentClientId || undefined,
maxUsers: formData.maxUsers,
maxStorage: formData.maxStorage,
address: formData.address || undefined,
phoneNumber: formData.phoneNumber || undefined,
website: formData.website || undefined,
};
const res = await createTenant(createData);
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to create tenant",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
} else {
Swal.fire({
title: "Success",
text: "Tenant created successfully",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
await loadData();
setIsDialogOpen(false);
}
} catch (error) {
console.error("Error saving tenant:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
}
};
const handleDelete = async (tenant: Tenant) => {
const result = await Swal.fire({
title: "Delete Tenant?",
text: `Are you sure you want to delete "${tenant.name}"? This action cannot be undone.`,
icon: "warning",
showCancelButton: true,
confirmButtonText: "Yes, delete it",
cancelButtonText: "Cancel",
customClass: {
popup: 'swal-z-index-9999'
}
});
if (result.isConfirmed) {
try {
const res = await deleteTenant(tenant.id);
if (res?.error) {
Swal.fire({
title: "Error",
text: res?.message || "Failed to delete tenant",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
} else {
Swal.fire({
title: "Deleted!",
text: "Tenant has been deleted.",
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
await loadData();
}
} catch (error) {
console.error("Error deleting tenant:", error);
Swal.fire({
title: "Error",
text: "An unexpected error occurred",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: 'swal-z-index-9999'
}
});
}
}
};
const roleId = getCookiesDecrypt("urie");
if (Number(roleId) !== 1) {
return null; // Will redirect in useEffect
}
return (
<>
<SiteBreadcrumb />
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Tenant Management</h1>
<p className="text-gray-600 mt-2">
Manage system tenants and their configurations
</p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2" onClick={() => handleOpenDialog()}>
<PlusIcon className="h-4 w-4" />
Create Tenant
</Button>
</DialogTrigger>
{/* @ts-ignore - DialogContent accepts children */}
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New Tenant</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<FormField
label="Tenant Name"
name="name"
type="text"
placeholder="e.g., Company ABC, Organization XYZ"
value={formData.name}
onChange={(value) => setFormData({ ...formData, name: value })}
required
/>
<FormField
label="Description"
name="description"
type="textarea"
placeholder="Brief description of the tenant"
value={formData.description}
onChange={(value) => setFormData({ ...formData, description: value })}
/>
<FormField
label="Client Type"
name="clientType"
type="select"
placeholder="Select client type"
value={formData.clientType}
onChange={(value) => setFormData({ ...formData, clientType: value as any })}
options={[
{ value: "standalone", label: "Standalone" },
{ value: "parent_client", label: "Parent Client" },
{ value: "sub_client", label: "Sub Client" },
]}
required
/>
{formData.clientType === "sub_client" && (
<FormField
label="Parent Tenant"
name="parentClientId"
type="select"
placeholder="Select parent tenant"
value={formData.parentClientId || "none"}
onChange={(value) => setFormData({ ...formData, parentClientId: value === "none" ? undefined : value })}
options={[
{ value: "none", label: "Select a parent tenant" },
...parentTenants.map((tenant) => ({
value: tenant.id,
label: tenant.name,
})),
]}
required
/>
)}
<div className="grid grid-cols-2 gap-4">
<FormField
label="Max Users"
name="maxUsers"
type="number"
placeholder="e.g., 100"
value={formData.maxUsers?.toString() || ""}
onChange={(value) => setFormData({ ...formData, maxUsers: value ? Number(value) : undefined })}
helpText="Maximum number of users allowed"
/>
<FormField
label="Max Storage (MB)"
name="maxStorage"
type="number"
placeholder="e.g., 10000"
value={formData.maxStorage?.toString() || ""}
onChange={(value) => setFormData({ ...formData, maxStorage: value ? Number(value) : undefined })}
helpText="Maximum storage in MB"
/>
</div>
<FormField
label="Address"
name="address"
type="textarea"
placeholder="Tenant address"
value={formData.address}
onChange={(value) => setFormData({ ...formData, address: value })}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
label="Phone Number"
name="phoneNumber"
type="tel"
placeholder="e.g., +62 123 456 7890"
value={formData.phoneNumber}
onChange={(value) => setFormData({ ...formData, phoneNumber: value })}
/>
<FormField
label="Website"
name="website"
type="url"
placeholder="e.g., https://example.com"
value={formData.website}
onChange={(value) => setFormData({ ...formData, website: value })}
/>
</div>
<div className="flex items-center justify-end gap-2 pt-4 border-t">
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button onClick={handleSave}>
Create Tenant
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading tenants...</p>
</div>
) : tenants.length > 0 ? (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Parent</TableHead>
<TableHead>Address</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tenants.map((tenant) => {
const parentTenant = tenants.find((t) => t.id === tenant.parentClientId);
return (
<TableRow key={tenant.id}>
<TableCell className="font-medium">{tenant.name}</TableCell>
<TableCell>
<span className={`px-2 py-1 text-xs rounded-full ${
tenant.clientType === "parent_client"
? "bg-blue-100 text-blue-800"
: tenant.clientType === "sub_client"
? "bg-purple-100 text-purple-800"
: "bg-gray-100 text-gray-800"
}`}>
{tenant.clientType.replace("_", " ")}
</span>
</TableCell>
<TableCell>{parentTenant?.name || "-"}</TableCell>
<TableCell className="max-w-xs truncate">{tenant.address || "-"}</TableCell>
<TableCell>{tenant.phoneNumber || "-"}</TableCell>
<TableCell>
{tenant.isActive ? (
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
Active
</span>
) : (
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-800 rounded-full">
Inactive
</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/admin/tenants/${tenant.id}/edit`)}
>
<EditIcon className="h-4 w-4 mr-2" />
Edit
</Button>
<Button
variant="outline"
size="sm"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDelete(tenant)}
>
<DeleteIcon className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<div className="text-center">
<div className="h-12 w-12 text-gray-400 mx-auto mb-4 flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="w-12 h-12"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008zm0 3h.008v.008h-.008v-.008z"
/>
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">No Tenants Found</h3>
<p className="text-gray-500 mb-4">
Create your first tenant to get started
</p>
<Button onClick={() => handleOpenDialog()}>
<PlusIcon className="h-4 w-4 mr-2" />
Create Tenant
</Button>
</div>
</CardContent>
</Card>
)}
</div>
</>
);
}

View File

@ -6,52 +6,21 @@ import ThemeCustomize from "@/components/partials/customizer";
import DashCodeHeader from "@/components/partials/header";
import MountedProvider from "@/providers/mounted.provider";
import { WorkflowModalProvider } from "@/components/modals/WorkflowModalProvider";
import { PermissionProvider } from "@/components/context/permission-context";
const layout = async ({ children }: { children: React.ReactNode }) => {
return (
<MountedProvider isProtected={true}>
{/* 🔐 Permission hanya untuk admin */}
<PermissionProvider>
<LayoutProvider>
<WorkflowModalProvider>
<ThemeCustomize />
<DashCodeHeader />
<DashCodeSidebar />
<LayoutContentProvider>{children}</LayoutContentProvider>
<DashCodeFooter />
</WorkflowModalProvider>
</LayoutProvider>
</PermissionProvider>
<LayoutProvider>
<WorkflowModalProvider>
<ThemeCustomize />
<DashCodeHeader />
<DashCodeSidebar />
<LayoutContentProvider>{children}</LayoutContentProvider>
<DashCodeFooter />
</WorkflowModalProvider>
</LayoutProvider>
</MountedProvider>
);
};
export default layout;
// import LayoutProvider from "@/providers/layout.provider";
// import LayoutContentProvider from "@/providers/content.provider";
// import DashCodeSidebar from "@/components/partials/sidebar";
// import DashCodeFooter from "@/components/partials/footer";
// import ThemeCustomize from "@/components/partials/customizer";
// import DashCodeHeader from "@/components/partials/header";
// import MountedProvider from "@/providers/mounted.provider";
// import { WorkflowModalProvider } from "@/components/modals/WorkflowModalProvider";
// const layout = async ({ children }: { children: React.ReactNode }) => {
// return (
// <MountedProvider isProtected={true}>
// <LayoutProvider>
// <WorkflowModalProvider>
// <ThemeCustomize />
// <DashCodeHeader />
// <DashCodeSidebar />
// <LayoutContentProvider>{children}</LayoutContentProvider>
// <DashCodeFooter />
// </WorkflowModalProvider>
// </LayoutProvider>
// </MountedProvider>
// );
// };
// export default layout;

View File

@ -70,9 +70,7 @@ const AuthPage = () => {
const handleOTPSuccess = async () => {
if (loginCredentials) {
try {
await login(loginCredentials, { skipRedirect: false });
// await login(loginCredentials);
await login(loginCredentials);
} catch (error: any) {
toast.error(error.message || "Login failed after OTP verification");
}

View File

@ -1,142 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ================================
GLOBAL CSS VARIABLE (LIGHT MODE)
================================ */
:root {
--radius: 0.625rem;
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 215.3 19.3% 34.5%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--success: 154 52% 55%;
--warning: 16 93% 70%;
--info: 185 96% 51%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 215 20% 65%;
}
/* ================================
DARK MODE VARIABLES
================================ */
.dark {
--background: 222.2 47.4% 11.2%;
--foreground: 210 40% 98%;
--card: 215 27.9% 16.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 215.3 25% 26.7%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--sidebar: 215 27.9% 16.9%;
--sidebar-foreground: 214.3 31.8% 91.4%;
}
/* ================================
BASE LAYER
================================ */
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* ================================
GLOBAL UTILITIES
================================ */
@layer utilities {
/* SweetAlert z-index fix */
.swal-z-index-9999 {
z-index: 9999 !important;
}
/* Scrollbar hide */
.no-scrollbar::-webkit-scrollbar {
width: 0px;
}
.no-scrollbar::-webkit-scrollbar-thumb {
background-color: transparent;
}
/* Input group helpers */
.input-group :not(:first-child) input {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
.input-group.merged :not(:first-child) input {
border-left-width: 0 !important;
padding-left: 0 !important;
}
.input-group :not(:last-child) input {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.input-group.merged :not(:last-child) input {
border-right-width: 0 !important;
padding-right: 0 !important;
}
}
/* @import "tailwindcss";
@import "tailwindcss";
@import "tw-animate-css";
/* SweetAlert2 z-index fix */
.swal-z-index-9999 {
z-index: 9999 !important;
}
@ -432,4 +297,4 @@
.no-scrollbar::-webkit-scrollbar-thumb {
background-color: transparent;
}
} */
}

View File

@ -7,7 +7,7 @@ import Navbar from "@/components/landing-page/navbar";
export default function Home() {
return (
<div className="relative min-h-screen font-[family-name:var(--font-geist-sans)]">
<div className="relative z-10 bg-white dark:bg-default-50 w-full mx-auto">
<div className="relative z-10 bg-white w-full mx-auto">
<Navbar />
<div className="flex-1">
<Header />

View File

@ -1,31 +0,0 @@
import { usePermission } from "./context/permission-context";
export function AccessGuard({
module,
action,
children,
fallback = null,
}: {
module?: string;
action?: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}) {
const { canModule, canAction, can, loading } = usePermission();
if (loading) return null;
// ❗ WAJIB ADA RULE
if (!module && !action) {
if (process.env.NODE_ENV === "development") {
console.warn("AccessGuard requires module and/or action");
}
return fallback;
}
if (module && action && !can(module, action)) return fallback;
if (module && !action && !canModule(module)) return fallback;
if (!module && action && !canAction(action)) return fallback;
return <>{children}</>;
}

View File

@ -19,7 +19,7 @@ interface FormFieldProps {
showPasswordToggle?: boolean;
onPasswordToggle?: () => void;
showPassword?: boolean;
}
}
export const FormField: React.FC<FormFieldProps> = ({
label,

View File

@ -28,7 +28,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({
const { login } = useAuth();
const t = useTranslations("MediaUpdate");
const [showPassword, setShowPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const [rememberMe, setRememberMe] = useState(true);
const [roles, setRoles] = useState<Role[]>([]);
const [selectedCategory, setSelectedCategory] = useState("5");
const [isDialogOpen, setIsDialogOpen] = useState(false);
@ -62,45 +62,21 @@ export const LoginForm: React.FC<LoginFormProps> = ({
const handleLogin = async (data: LoginFormData) => {
try {
await login(data, { skipRedirect: true });
// await login(data);
await login(data);
onSuccess?.(data);
} catch (error: any) {
const message = getLoginErrorMessage(error);
onError?.(message);
}
};
// const onSubmit = async (data: LoginFormData) => {
// try {
// // onSuccess?.(data);
// await handleLogin(data);
// } catch (error: any) {
// onError?.(error.message || "Login failed");
// }
// };
const onSubmit = async (data: LoginFormData) => {
try {
onSuccess?.(data); // hanya kirim data ke AuthPage
} catch (error: any) {
onError?.(error.message || "Login failed");
}
};
const getLoginErrorMessage = (error: any): string => {
const data = error?.response?.data;
// Backend Netidhub
if (Array.isArray(data?.messages) && data.messages.length > 0) {
return data.messages[0];
const onSubmit = async (data: LoginFormData) => {
try {
// Pass the form data to the parent component
// The auth page will handle email validation and flow transitions
onSuccess?.(data);
} catch (error: any) {
onError?.(error.message || "Login failed");
}
// Fallback lain
if (typeof data?.message === "string") return data.message;
if (typeof error?.message === "string") return error.message;
return "Username atau kata sandi salah";
};
return (
@ -201,20 +177,6 @@ export const LoginForm: React.FC<LoginFormProps> = ({
{/* Remember Me and Forgot Password */}
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
<input
id="rememberMe"
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
disabled={isSubmitting}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<label htmlFor="rememberMe" className="text-sm cursor-pointer">
{t("rememberMe")}
</label>
</div>
{/* <div className="flex gap-2 items-center">
<Checkbox
id="rememberMe"
checked={rememberMe}
@ -224,7 +186,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({
<Label htmlFor="rememberMe" className="text-sm">
{t("rememberMe")}
</Label>
</div> */}
</div>
<Link
href="/auth/forgot-password"
className="text-sm text-default-800 dark:text-default-400 leading-6 font-medium hover:underline"

View File

@ -23,7 +23,6 @@ export const OTPForm: React.FC<OTPFormProps> = ({
const [otpValue, setOtpValue] = useState("");
const t = useTranslations("MediaUpdate");
const handleTypeOTP = (event: React.KeyboardEvent<HTMLInputElement>) => {
const { key } = event;
const target = event.currentTarget;
@ -58,12 +57,9 @@ export const OTPForm: React.FC<OTPFormProps> = ({
try {
const isValid = await verifyOTP(loginCredentials.username, otpValue);
if (isValid) {
onSuccess?.();
}
else {
} else {
onError?.("Invalid OTP code");
}
} catch (error: any) {
@ -158,6 +154,7 @@ export const OTPForm: React.FC<OTPFormProps> = ({
{loading ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div>
) : (
t("enterOTP4")

View File

@ -1,116 +0,0 @@
"use client";
import React, { createContext, useContext, useEffect, useState } from "react";
import { getCookiesDecrypt } from "@/lib/utils";
import { getUserLevelModuleAccessesByUserLevelId } from "@/service/user-level-module-accesses";
import { getUserLevelMenuActionAccesses } from "@/service/user-level-menu-action-accesses";
import { getUserInfo } from "@/service/user";
type ModuleAccessMap = Record<string, boolean>;
type ActionAccessMap = Record<string, string[]>;
interface PermissionContextType {
canModule: (moduleCode: string) => boolean;
canAction: (actionCode: string) => boolean;
can: (moduleCode: string, actionCode: string) => boolean;
loading: boolean;
}
const PermissionContext = createContext<PermissionContextType | null>(null);
export const PermissionProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [moduleAccess, setModuleAccess] = useState<ModuleAccessMap>({});
const [actionCodes, setActionCodes] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadPermissions = async () => {
try {
// ✅ SUMBER KEBENARAN: API USER INFO
const userRes = await getUserInfo();
const userLevelId = userRes?.data?.data?.userLevelId;
console.log("USER LEVEL ID FROM API:", userLevelId);
if (!userLevelId) {
console.error("userLevelId not found from users/info");
return;
}
const [moduleRes, actionRes] = await Promise.all([
getUserLevelModuleAccessesByUserLevelId(userLevelId),
getUserLevelMenuActionAccesses({
userLevelId,
canAccess: true,
limit: 10000,
}),
]);
console.log("ACTION ACCESS RAW:", actionRes?.data?.data);
// 🔹 MODULE ACCESS
// MODULE ACCESS
const moduleMap: Record<string, boolean> = {};
moduleRes?.data?.data?.forEach((item: any) => {
if (
item.module?.code &&
item.canAccess === true &&
item.isActive !== false
) {
moduleMap[item.module.code] = true;
}
});
// ACTION ACCESS
const actions =
actionRes?.data?.data
?.filter(
(item: any) =>
Number(item.userLevelId) === Number(userLevelId) &&
item.canAccess === true &&
item.isActive === true,
)
.map((item: any) => item.actionCode) ?? [];
setModuleAccess(moduleMap);
setActionCodes(actions);
} catch (error) {
console.error("Failed to load permissions", error);
} finally {
setLoading(false);
}
};
loadPermissions();
}, []);
const canModule = (moduleCode: string) => moduleAccess[moduleCode] === true;
const canAction = (actionCode: string) => actionCodes.includes(actionCode);
/**
* FINAL GUARD
* - harus punya module
* - harus punya action
*/
const can = (moduleCode: any, actionCode: any) =>
canModule(moduleCode) && canAction(actionCode);
return (
<PermissionContext.Provider value={{ canModule, canAction, can, loading }}>
{children}
</PermissionContext.Provider>
);
};
export const usePermission = () => {
const ctx = useContext(PermissionContext);
if (!ctx) {
throw new Error("usePermission must be used inside PermissionProvider");
}
return ctx;
};

View File

@ -2,213 +2,40 @@
import React from "react";
import { CKEditor } from "@ckeditor/ckeditor5-react";
import Editor from "ckeditor5-custom-build";
import Editor from "@/vendor/ckeditor5/build/ckeditor";
function CustomEditor(props) {
const maxHeight = props.maxHeight || 600;
return (
<div className="ckeditor-wrapper">
<CKEditor
editor={Editor}
data={props.initialData}
onChange={(event, editor) => {
const data = editor.getData();
console.log({ event, editor, data });
props.onChange(data);
}}
config={{
toolbar: [
"heading",
"fontsize",
"bold",
"italic",
"link",
"numberedList",
"bulletedList",
"undo",
"redo",
"alignment",
"outdent",
"indent",
"blockQuote",
"insertTable",
"codeBlock",
"sourceEditing",
],
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #111 !important;
background: #fff !important;
margin: 0;
padding: 1rem;
}
p {
margin: 0.5em 0;
}
h1, h2, h3, h4, h5, h6 {
margin: 1em 0 0.5em 0;
color: inherit !important;
}
ul, ol {
margin: 0.5em 0;
padding-left: 2em;
}
blockquote {
margin: 1em 0;
padding: 0.5em 1em;
border-left: 4px solid #d1d5db;
background-color: #f9fafb;
color: inherit !important;
}
`,
height: props.height || 400,
removePlugins: ["Title"],
mobile: {
theme: "silver",
},
}}
/>
<style jsx>{`
.ckeditor-wrapper {
border-radius: 6px;
overflow: hidden;
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.ckeditor-wrapper :global(.ck.ck-editor__main) {
min-height: ${props.height || 400}px;
max-height: ${maxHeight}px;
}
.ckeditor-wrapper :global(.ck.ck-editor__editable) {
min-height: ${(props.height || 400) - 50}px;
max-height: ${maxHeight - 50}px;
overflow-y: auto !important;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
background: #fff !important;
color: #111 !important;
}
/* Dark mode support */
:global(.dark) .ckeditor-wrapper :global(.ck.ck-editor__editable) {
background: #111 !important;
color: #f9fafb !important;
}
:global(.dark) .ckeditor-wrapper :global(.ck.ck-editor__editable h1),
:global(.dark) .ckeditor-wrapper :global(.ck.ck-editor__editable h2),
:global(.dark) .ckeditor-wrapper :global(.ck.ck-editor__editable h3),
:global(.dark) .ckeditor-wrapper :global(.ck.ck-editor__editable h4),
:global(.dark) .ckeditor-wrapper :global(.ck.ck-editor__editable h5),
:global(.dark) .ckeditor-wrapper :global(.ck.ck-editor__editable h6) {
color: #f9fafb !important;
}
:global(.dark)
.ckeditor-wrapper
:global(.ck.ck-editor__editable blockquote) {
background-color: #1f2937 !important;
border-left-color: #374151 !important;
color: #f3f4f6 !important;
}
/* Custom scrollbar styling for webkit browsers */
.ckeditor-wrapper :global(.ck.ck-editor__editable::-webkit-scrollbar) {
width: 8px;
}
.ckeditor-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-track) {
background: #f1f5f9;
border-radius: 4px;
}
.ckeditor-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb) {
background: #cbd5e1;
border-radius: 4px;
}
.ckeditor-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb:hover) {
background: #94a3b8;
}
/* Dark mode scrollbar */
:global(.dark)
.ckeditor-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-track) {
background: #1f2937;
}
:global(.dark)
.ckeditor-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb) {
background: #4b5563;
}
:global(.dark)
.ckeditor-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb:hover) {
background: #6b7280;
}
/* Ensure content doesn't overflow */
.ckeditor-wrapper :global(.ck.ck-editor__editable .ck-content) {
overflow: hidden;
}
`}</style>
</div>
<CKEditor
editor={Editor}
data={props.initialData}
onChange={(event, editor) => {
const data = editor.getData();
console.log({ event, editor, data });
props.onChange(data);
}}
config={{
toolbar: [
"heading",
"fontsize",
"bold",
"italic",
"link",
"numberedList",
"bulletedList",
"undo",
"redo",
"alignment",
"outdent",
"indent",
"blockQuote",
"insertTable",
"codeBlock",
"sourceEditing",
],
}}
/>
);
}
export default CustomEditor;
// // components/custom-editor.js
// import React from "react";
// import { CKEditor } from "@ckeditor/ckeditor5-react";
// import Editor from "@/vendor/ckeditor5/build/ckeditor";
// function CustomEditor(props) {
// return (
// <CKEditor
// editor={Editor}
// data={props.initialData}
// onChange={(event, editor) => {
// const data = editor.getData();
// console.log({ event, editor, data });
// props.onChange(data);
// }}
// config={{
// toolbar: [
// "heading",
// "fontsize",
// "bold",
// "italic",
// "link",
// "numberedList",
// "bulletedList",
// "undo",
// "redo",
// "alignment",
// "outdent",
// "indent",
// "blockQuote",
// "insertTable",
// "codeBlock",
// "sourceEditing",
// ],
// }}
// />
// );
// }
// export default CustomEditor;

View File

@ -1,171 +1,19 @@
import React from "react";
import { CKEditor } from "@ckeditor/ckeditor5-react";
import Editor from "ckeditor5-custom-build";
import Editor from "@/vendor/ckeditor5/build/ckeditor";
function ViewEditor(props) {
const maxHeight = props.maxHeight || 600; // Default max height 600px
return (
<div className="ckeditor-view-wrapper">
<CKEditor
editor={Editor}
data={props.initialData}
disabled={true}
config={{
isReadOnly: true,
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #111;
background: #fff;
margin: 0;
padding: 0;
}
p {
margin: 0.5em 0;
}
h1, h2, h3, h4, h5, h6 {
margin: 1em 0 0.5em 0;
}
ul, ol {
margin: 0.5em 0;
padding-left: 2em;
}
blockquote {
margin: 1em 0;
padding: 0.5em 1em;
border-left: 4px solid #d1d5db;
background-color: #f9fafb;
}
`,
height: props.height || 400,
removePlugins: ["Title"],
}}
/>
<style jsx>{`
.ckeditor-view-wrapper {
border-radius: 6px;
overflow: hidden;
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.ckeditor-view-wrapper :global(.ck.ck-editor__main) {
min-height: ${props.height || 400}px;
max-height: ${maxHeight}px;
}
.ckeditor-view-wrapper :global(.ck.ck-editor__editable) {
min-height: ${(props.height || 400) - 50}px;
max-height: ${maxHeight - 50}px;
overflow-y: auto !important;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
background-color: #fdfdfd;
border: 1px solid #d1d5db;
border-radius: 6px;
color: #111;
}
/* 🌙 Dark mode support */
:global(.dark) .ckeditor-view-wrapper :global(.ck.ck-editor__editable) {
background-color: #111 !important;
color: #f9fafb !important;
border-color: #374151;
}
:global(.dark) .ckeditor-view-wrapper h1,
:global(.dark) .ckeditor-view-wrapper h2,
:global(.dark) .ckeditor-view-wrapper h3,
:global(.dark) .ckeditor-view-wrapper h4,
:global(.dark) .ckeditor-view-wrapper h5,
:global(.dark) .ckeditor-view-wrapper h6 {
color: #f9fafb !important;
}
:global(.dark) .ckeditor-view-wrapper blockquote {
background-color: #1f2937 !important;
border-left: 4px solid #374151 !important;
color: #f3f4f6 !important;
}
/* Custom scrollbar styling */
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar) {
width: 8px;
}
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-track) {
background: #f1f5f9;
border-radius: 4px;
}
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb) {
background: #cbd5e1;
border-radius: 4px;
}
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb:hover) {
background: #94a3b8;
}
/* 🌙 Dark mode scrollbar */
:global(.dark)
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-track) {
background: #1f2937;
}
:global(.dark)
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb) {
background: #4b5563;
}
:global(.dark)
.ckeditor-view-wrapper
:global(.ck.ck-editor__editable::-webkit-scrollbar-thumb:hover) {
background: #6b7280;
}
/* Read-only specific styling */
.ckeditor-view-wrapper :global(.ck.ck-editor__editable.ck-read-only) {
cursor: default;
}
/* Hide toolbar */
.ckeditor-view-wrapper :global(.ck.ck-toolbar) {
display: none !important;
}
`}</style>
</div>
<CKEditor
editor={Editor}
data={props.initialData}
disabled={true}
config={{
// toolbar: [],
isReadOnly: true,
}}
/>
);
}
export default ViewEditor;
// import React from "react";
// import { CKEditor } from "@ckeditor/ckeditor5-react";
// import Editor from "@/vendor/ckeditor5/build/ckeditor";
// function ViewEditor(props) {
// return (
// <CKEditor
// editor={Editor}
// data={props.initialData}
// disabled={true}
// config={{
// // toolbar: [],
// isReadOnly: true,
// }}
// />
// );
// }
// export default ViewEditor;

View File

@ -33,18 +33,6 @@ interface ApprovalWorkflowFormProps {
isLoading?: boolean;
}
const normalizeClientSettings = (settings: ClientApprovalSettingsRequest) => ({
approvalExemptCategories: settings.approvalExemptCategories ?? [],
approvalExemptRoles: settings.approvalExemptRoles ?? [],
approvalExemptUsers: settings.approvalExemptUsers ?? [],
requireApprovalFor: settings.requireApprovalFor ?? [],
skipApprovalFor: settings.skipApprovalFor ?? [],
autoPublishArticles: settings.autoPublishArticles ?? false,
requiresApproval: settings.requiresApproval ?? false,
isActive: settings.isActive ?? false,
});
export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
initialData,
workflowId,
@ -53,34 +41,31 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
isLoading = false,
}) => {
// Form state
const [formData, setFormData] =
useState<CreateApprovalWorkflowWithClientSettingsRequest>({
name: "",
description: "",
isActive: true,
isDefault: true,
const [formData, setFormData] = useState<CreateApprovalWorkflowWithClientSettingsRequest>({
name: "",
description: "",
isActive: true,
isDefault: true,
requiresApproval: true,
autoPublish: false,
steps: [],
clientApprovalSettings: {
requiresApproval: true,
autoPublish: false,
steps: [],
clientApprovalSettings: {
requiresApproval: true,
autoPublishArticles: false,
approvalExemptUsers: [],
approvalExemptRoles: [],
approvalExemptCategories: [],
requireApprovalFor: [],
skipApprovalFor: [],
isActive: true,
},
});
autoPublishArticles: false,
approvalExemptUsers: [],
approvalExemptRoles: [],
approvalExemptCategories: [],
requireApprovalFor: [],
skipApprovalFor: [],
isActive: true,
},
});
// API data
const [userLevels, setUserLevels] = useState<UserLevel[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
const [articleCategories, setArticleCategories] = useState<ArticleCategory[]>(
[],
);
const [articleCategories, setArticleCategories] = useState<ArticleCategory[]>([]);
// UI state
const [errors, setErrors] = useState<Record<string, string>>({});
@ -91,28 +76,25 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
// Get available user levels for a specific step (excluding already selected ones)
const getAvailableUserLevels = (currentStepIndex: number) => {
const usedLevelIds = new Set<number>();
// Collect all user level IDs that are already used by other steps
formData.steps.forEach((step, stepIndex) => {
if (stepIndex !== currentStepIndex && step.conditionValue) {
try {
const conditionData = JSON.parse(step.conditionValue);
if (
conditionData.applies_to_levels &&
Array.isArray(conditionData.applies_to_levels)
) {
if (conditionData.applies_to_levels && Array.isArray(conditionData.applies_to_levels)) {
conditionData.applies_to_levels.forEach((levelId: number) => {
usedLevelIds.add(levelId);
});
}
} catch (error) {
console.error("Error parsing conditionValue:", error);
console.error('Error parsing conditionValue:', error);
}
}
});
// Filter out used levels and return available ones
return userLevels.filter((level) => !usedLevelIds.has(level.id));
return userLevels.filter(level => !usedLevelIds.has(level.id));
};
// Load initial data
@ -126,20 +108,17 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
useEffect(() => {
const loadData = async () => {
try {
const [userLevelsRes, usersRes, userRolesRes, categoriesRes] =
await Promise.all([
getUserLevels(),
getUsers(),
getUserRoles(),
getArticleCategories(),
]);
const [userLevelsRes, usersRes, userRolesRes, categoriesRes] = await Promise.all([
getUserLevels(),
getUsers(),
getUserRoles(),
getArticleCategories(),
]);
if (!userLevelsRes?.error)
setUserLevels(userLevelsRes?.data?.data || []);
if (!userLevelsRes?.error) setUserLevels(userLevelsRes?.data?.data || []);
if (!usersRes?.error) setUsers(usersRes?.data || []);
if (!userRolesRes?.error) setUserRoles(userRolesRes?.data || []);
if (!categoriesRes?.error)
setArticleCategories(categoriesRes?.data || []);
if (!categoriesRes?.error) setArticleCategories(categoriesRes?.data || []);
} catch (error) {
console.error("Error loading form data:", error);
} finally {
@ -176,12 +155,10 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
newErrors[`steps.${index}.stepName`] = "Step name is required";
}
if (!step.requiredUserLevelId) {
newErrors[`steps.${index}.requiredUserLevelId`] =
"Required user level is required";
newErrors[`steps.${index}.requiredUserLevelId`] = "Required user level is required";
}
if (step.stepOrder <= 0) {
newErrors[`steps.${index}.stepOrder`] =
"Step order must be greater than 0";
newErrors[`steps.${index}.stepOrder`] = "Step order must be greater than 0";
}
});
@ -190,13 +167,10 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
};
// Form handlers
const handleBasicInfoChange = (
field: keyof CreateApprovalWorkflowWithClientSettingsRequest,
value: any,
) => {
setFormData((prev) => ({ ...prev, [field]: value }));
const handleBasicInfoChange = (field: keyof CreateApprovalWorkflowWithClientSettingsRequest, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: "" }));
setErrors(prev => ({ ...prev, [field]: "" }));
}
};
@ -214,26 +188,17 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
// Keep existing stepOrder if manually set
return step;
});
setFormData((prev) => ({ ...prev, steps: updatedSteps }));
setFormData(prev => ({ ...prev, steps: updatedSteps }));
};
const renderStepForm = (
step: ApprovalWorkflowStepRequest,
index: number,
onUpdate: (step: ApprovalWorkflowStepRequest) => void,
onDelete: () => void,
) => {
const stepErrors = Object.keys(errors).filter((key) =>
key.startsWith(`steps.${index}`),
);
const renderStepForm = (step: ApprovalWorkflowStepRequest, index: number, onUpdate: (step: ApprovalWorkflowStepRequest) => void, onDelete: () => void) => {
const stepErrors = Object.keys(errors).filter(key => key.startsWith(`steps.${index}`));
// Check if this step has parallel steps (same stepOrder)
const parallelSteps = formData.steps.filter(
(s, i) => s.stepOrder === step.stepOrder && i !== index,
);
const parallelSteps = formData.steps.filter((s, i) => s.stepOrder === step.stepOrder && i !== index);
const isParallelStep = parallelSteps.length > 0;
return (
<div className="space-y-4">
{/* Parallel Step Indicator */}
@ -257,12 +222,7 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
type="number"
placeholder="1"
value={step.stepOrder}
onChange={(value) =>
onUpdate({
...step,
stepOrder: value ? Number(value) : index + 1,
})
}
onChange={(value) => onUpdate({ ...step, stepOrder: value ? Number(value) : index + 1 })}
error={errors[`steps.${index}.stepOrder`]}
min={1}
helpText="Same order = parallel steps"
@ -283,31 +243,17 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
label="Required User Level"
name={`requiredUserLevelId-${index}`}
type="select"
placeholder={
userLevels.length > 0
? "Select user level"
: "No user levels available"
}
placeholder={userLevels.length > 0 ? "Select user level" : "No user levels available"}
value={step.requiredUserLevelId}
onChange={(value) =>
onUpdate({ ...step, requiredUserLevelId: Number(value) })
}
onChange={(value) => onUpdate({ ...step, requiredUserLevelId: Number(value) })}
error={errors[`steps.${index}.requiredUserLevelId`]}
options={
userLevels.length > 0
? userLevels.map((level) => ({
value: level.id,
label: `${level.name} (Level ${level.levelNumber})`,
}))
: []
}
options={userLevels.length > 0 ? userLevels.map(level => ({
value: level.id,
label: `${level.name} (Level ${level.levelNumber})`,
})) : []}
required
disabled={userLevels.length === 0}
helpText={
userLevels.length === 0
? "No user levels found. Please create user levels first."
: undefined
}
helpText={userLevels.length === 0 ? "No user levels found. Please create user levels first." : undefined}
/>
</div>
@ -318,12 +264,7 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
type="number"
placeholder="Leave empty for manual approval"
value={step.autoApproveAfterHours}
onChange={(value) =>
onUpdate({
...step,
autoApproveAfterHours: value ? Number(value) : undefined,
})
}
onChange={(value) => onUpdate({ ...step, autoApproveAfterHours: value ? Number(value) : undefined })}
helpText="Automatically approve after specified hours"
min={1}
/>
@ -336,7 +277,7 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
value={step.canSkip || false}
onChange={(value) => onUpdate({ ...step, canSkip: value })}
/>
<FormField
label="Is Active"
name={`isActive-${index}`}
@ -353,42 +294,32 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
label="Applies to User Levels"
placeholder={(() => {
const availableLevels = getAvailableUserLevels(index);
return availableLevels.length > 0
? "Select user levels..."
: "No available user levels";
return availableLevels.length > 0 ? "Select user levels..." : "No available user levels";
})()}
options={(() => {
const availableLevels = getAvailableUserLevels(index);
return availableLevels.map((level) => ({
return availableLevels.map(level => ({
value: level.id,
label: `${level.name} (Level ${level.levelNumber})`,
}));
})()}
value={(() => {
try {
return step.conditionValue
? JSON.parse(step.conditionValue).applies_to_levels || []
: [];
return step.conditionValue ? JSON.parse(step.conditionValue).applies_to_levels || [] : [];
} catch {
return [];
}
})()}
onChange={(value) => {
const conditionValue = JSON.stringify({
applies_to_levels: value,
});
onUpdate({
...step,
conditionType: "user_level_hierarchy",
conditionValue,
});
const conditionValue = JSON.stringify({ applies_to_levels: value });
onUpdate({ ...step, conditionType: "user_level_hierarchy", conditionValue });
}}
searchable={true}
disabled={getAvailableUserLevels(index).length === 0}
helpText={(() => {
const availableLevels = getAvailableUserLevels(index);
const usedLevels = userLevels.length - availableLevels.length;
if (availableLevels.length === 0) {
return "All user levels are already assigned to other steps";
} else if (usedLevels > 0) {
@ -403,13 +334,9 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
);
};
function normalizeBoolean(value?: boolean): boolean {
return value ?? false;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
Swal.fire({
title: "Validation Error",
@ -417,14 +344,14 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
popup: 'swal-z-index-9999'
}
});
return;
}
setIsSubmitting(true);
try {
// Hardcoded client approval settings
const hardcodedClientSettings = {
@ -435,23 +362,7 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
isActive: true,
requireApprovalFor: [],
requiresApproval: true,
skipApprovalFor: [],
};
const clientSettingsPayload = {
approvalExemptCategories: [],
approvalExemptRoles: [],
approvalExemptUsers: [],
requireApprovalFor: [],
skipApprovalFor: [],
autoPublishArticles:
formData.clientApprovalSettings.autoPublishArticles ?? false,
requiresApproval:
formData.clientApprovalSettings.requiresApproval ?? false,
isActive: formData.clientApprovalSettings.isActive ?? false,
skipApprovalFor: []
};
if (workflowId) {
@ -460,60 +371,30 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
workflowId,
name: formData.name,
description: formData.description,
isActive: normalizeBoolean(formData.isActive),
isDefault: normalizeBoolean(formData.isDefault),
steps: formData.steps.map((step) => ({
isActive: formData.isActive || false,
isDefault: formData.isDefault || false,
steps: formData.steps.map(step => ({
...step,
branchName: step.stepName,
branchName: step.stepName, // branchName should be same as stepName
})),
clientSettings: {
approvalExemptCategories: [],
approvalExemptRoles: [],
approvalExemptUsers: [],
requireApprovalFor: [],
skipApprovalFor: [],
// 🔥 AMBIL LANGSUNG DARI CHECKBOX UI
requiresApproval: normalizeBoolean(formData.requiresApproval),
autoPublishArticles: normalizeBoolean(formData.autoPublish),
isActive: normalizeBoolean(formData.isActive),
},
clientSettings: hardcodedClientSettings
};
// const updateData: UpdateApprovalWorkflowWithClientSettingsRequest = {
// workflowId,
// name: formData.name,
// description: formData.description,
// isActive: formData.isActive,
// isDefault: formData.isDefault,
// steps: formData.steps.map(step => ({
// ...step,
// branchName: step.stepName, // branchName should be same as stepName
// })),
// clientSettings: hardcodedClientSettings
// };
console.log("Update Data: ", updateData);
const response =
await updateApprovalWorkflowWithClientSettings(updateData);
const response = await updateApprovalWorkflowWithClientSettings(updateData);
console.log("Update Response: ", response);
if (response?.error) {
Swal.fire({
title: "Error",
text:
response?.message?.messages?.[0] ||
"Failed to update approval workflow",
text: response?.message?.messages?.[0] || "Failed to update approval workflow",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
popup: 'swal-z-index-9999'
}
});
} else {
Swal.fire({
@ -522,8 +403,8 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
popup: 'swal-z-index-9999'
}
}).then(() => {
// Call onSave to trigger parent refresh
if (onSave) {
@ -535,27 +416,24 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
// Create mode
const submitData = {
...formData,
clientApprovalSettings: hardcodedClientSettings,
clientApprovalSettings: hardcodedClientSettings
};
console.log("Create Data: ", submitData);
const response =
await createApprovalWorkflowWithClientSettings(submitData);
const response = await createApprovalWorkflowWithClientSettings(submitData);
console.log("Create Response: ", response);
if (response?.error) {
Swal.fire({
title: "Error",
text:
response?.message?.messages?.[0] ||
"Failed to create approval workflow",
text: response?.message?.messages?.[0] || "Failed to create approval workflow",
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
popup: 'swal-z-index-9999'
}
});
} else {
Swal.fire({
@ -564,8 +442,8 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
icon: "success",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
popup: 'swal-z-index-9999'
}
}).then(() => {
// Call onSave to trigger parent refresh
if (onSave) {
@ -582,8 +460,8 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
icon: "error",
confirmButtonText: "OK",
customClass: {
popup: "swal-z-index-9999",
},
popup: 'swal-z-index-9999'
}
});
} finally {
setIsSubmitting(false);
@ -599,8 +477,8 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
confirmButtonText: "Yes, reset",
cancelButtonText: "Cancel",
customClass: {
popup: "swal-z-index-9999",
},
popup: 'swal-z-index-9999'
}
}).then((result) => {
if (result.isConfirmed) {
setFormData({
@ -635,18 +513,12 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
<span className="ml-2 text-gray-600">Loading form data...</span>
</div>
)}
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="basic" disabled={isLoadingData}>
Basic Information
</TabsTrigger>
<TabsTrigger value="steps" disabled={isLoadingData}>
Workflow Steps
</TabsTrigger>
<TabsTrigger value="settings" disabled={isLoadingData}>
Client Settings
</TabsTrigger>
<TabsTrigger value="basic" disabled={isLoadingData}>Basic Information</TabsTrigger>
<TabsTrigger value="steps" disabled={isLoadingData}>Workflow Steps</TabsTrigger>
<TabsTrigger value="settings" disabled={isLoadingData}>Client Settings</TabsTrigger>
</TabsList>
{/* Basic Information Tab */}
@ -673,9 +545,7 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
type="textarea"
placeholder="Describe the purpose and process of this workflow"
value={formData.description}
onChange={(value) =>
handleBasicInfoChange("description", value)
}
onChange={(value) => handleBasicInfoChange("description", value)}
error={errors.description}
required
rows={4}
@ -695,9 +565,7 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
name="isDefault"
type="checkbox"
value={formData.isDefault}
onChange={(value) =>
handleBasicInfoChange("isDefault", value)
}
onChange={(value) => handleBasicInfoChange("isDefault", value)}
/>
<FormField
@ -705,9 +573,7 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
name="requiresApproval"
type="checkbox"
value={formData.requiresApproval}
onChange={(value) =>
handleBasicInfoChange("requiresApproval", value)
}
onChange={(value) => handleBasicInfoChange("requiresApproval", value)}
/>
<FormField
@ -715,9 +581,7 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
name="autoPublish"
type="checkbox"
value={formData.autoPublish}
onChange={(value) =>
handleBasicInfoChange("autoPublish", value)
}
onChange={(value) => handleBasicInfoChange("autoPublish", value)}
/>
</div>
</CardContent>
@ -731,22 +595,14 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
<CardTitle className="flex items-center justify-between">
<span>Workflow Steps Configuration</span>
<div className="text-sm text-gray-500">
{formData.steps.length} step
{formData.steps.length !== 1 ? "s" : ""}
{formData.steps.length} step{formData.steps.length !== 1 ? 's' : ''}
{(() => {
const parallelGroups = formData.steps.reduce(
(acc, step) => {
acc[step.stepOrder] = (acc[step.stepOrder] || 0) + 1;
return acc;
},
{} as Record<number, number>,
);
const parallelCount = Object.values(parallelGroups).filter(
(count) => count > 1,
).length;
return parallelCount > 0
? `${parallelCount} parallel group${parallelCount !== 1 ? "s" : ""}`
: "";
const parallelGroups = formData.steps.reduce((acc, step) => {
acc[step.stepOrder] = (acc[step.stepOrder] || 0) + 1;
return acc;
}, {} as Record<number, number>);
const parallelCount = Object.values(parallelGroups).filter(count => count > 1).length;
return parallelCount > 0 ? `${parallelCount} parallel group${parallelCount !== 1 ? 's' : ''}` : '';
})()}
</div>
</CardTitle>
@ -780,44 +636,21 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
<div className="h-12 w-12 mx-auto mb-2 bg-gray-100 rounded-full flex items-center justify-center">
<span className="text-gray-400 text-xl"></span>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
Settings Pre-configured
</h3>
<h3 className="text-lg font-medium text-gray-900 mb-2">Settings Pre-configured</h3>
<p className="text-gray-600">
Client approval settings are automatically configured with
optimal defaults.
Client approval settings are automatically configured with optimal defaults.
</p>
</div>
<div className="bg-gray-50 rounded-lg p-4 text-left max-w-md mx-auto">
<h4 className="font-medium text-gray-900 mb-2">
Default Settings:
</h4>
<h4 className="font-medium text-gray-900 mb-2">Default Settings:</h4>
<ul className="text-sm text-gray-600 space-y-1">
<li>
Requires Approval:{" "}
<span className="font-medium text-green-600">Yes</span>
</li>
<li>
Auto Publish Articles:{" "}
<span className="font-medium text-green-600">Yes</span>
</li>
<li>
Is Active:{" "}
<span className="font-medium text-green-600">Yes</span>
</li>
<li>
Exempt Users:{" "}
<span className="font-medium text-gray-500">None</span>
</li>
<li>
Exempt Roles:{" "}
<span className="font-medium text-gray-500">None</span>
</li>
<li>
Exempt Categories:{" "}
<span className="font-medium text-gray-500">None</span>
</li>
<li> Requires Approval: <span className="font-medium text-green-600">Yes</span></li>
<li> Auto Publish Articles: <span className="font-medium text-green-600">Yes</span></li>
<li> Is Active: <span className="font-medium text-green-600">Yes</span></li>
<li> Exempt Users: <span className="font-medium text-gray-500">None</span></li>
<li> Exempt Roles: <span className="font-medium text-gray-500">None</span></li>
<li> Exempt Categories: <span className="font-medium text-gray-500">None</span></li>
</ul>
</div>
</div>
@ -851,7 +684,7 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
Cancel
</Button>
)}
<Button
type="submit"
variant="outline"
@ -859,13 +692,7 @@ export const ApprovalWorkflowForm: React.FC<ApprovalWorkflowFormProps> = ({
className="flex items-center gap-2"
>
<SaveIcon className="h-4 w-4" />
{isSubmitting
? "Saving..."
: isLoadingData
? "Loading..."
: workflowId
? "Update Workflow"
: "Save Workflow"}
{isSubmitting ? "Saving..." : isLoadingData ? "Loading..." : workflowId ? "Update Workflow" : "Save Workflow"}
</Button>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,6 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { formatDateToIndonesian } from "@/utils/globals";
import { getArticleCategoryDetail } from "@/service/categories/categories";
import { getUserInfo } from "@/service/user";
type CategoryDetail = {
id: number;
@ -26,32 +25,18 @@ type CategoryDetail = {
isActive: boolean;
createdAt: string;
updatedAt: string;
createdByFullname : string;
};
type CurrentUser = {
id: number;
username: string;
fullname?: string;
};
export default function CategoriesDetailForm() {
const { id } = useParams() as { id: string };
const router = useRouter();
const [detail, setDetail] = useState<CategoryDetail | null>(null);
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
useEffect(() => {
getUserInfo().then((res) => setCurrentUser(res.data.data));
}, []);
useEffect(() => {
async function init() {
if (id) {
try {
const res = await getArticleCategoryDetail(Number(id));
const user = await getUserInfo();
setDetail(res?.data?.data);
} catch (err) {
console.error("Error fetching category detail:", err);
@ -65,7 +50,7 @@ export default function CategoriesDetailForm() {
<form>
{detail ? (
<div className="flex flex-col lg:flex-row gap-10 border rounded-lg ">
{/* MAIN FORM */}
{/* MAIN FORM */}
<Card className="w-full lg:w-8/12 px-6 py-6 m-2">
<p className="text-lg font-semibold mb-3">Form Category Detail</p>
@ -76,10 +61,10 @@ export default function CategoriesDetailForm() {
</div>
{/* Slug */}
{/* <div className="space-y-2 py-3">
<div className="space-y-2 py-3">
<Label>Slug</Label>
<Input type="text" value={detail.slug || "-"} readOnly />
</div> */}
</div>
{/* Description */}
<div className="space-y-2 py-3">
@ -92,7 +77,7 @@ export default function CategoriesDetailForm() {
</div>
{/* Thumbnail */}
{/* <div className="space-y-2 py-3">
<div className="space-y-2 py-3">
<Label>Thumbnail</Label>
<Card className="mt-2 w-fit p-2 border">
<img
@ -101,10 +86,10 @@ export default function CategoriesDetailForm() {
className="h-[200px] rounded"
/>
</Card>
</div> */}
</div>
{/* Tags */}
{/* <div className="space-y-2 py-3 ">
<div className="space-y-2 py-3 ">
<Label>Tags</Label>
<div className="flex flex-wrap gap-2 p-4 border rounded-sm">
{detail?.tags?.length > 0 ? (
@ -117,7 +102,7 @@ export default function CategoriesDetailForm() {
<span className="text-slate-400 text-sm">No tags</span>
)}
</div>
</div> */}
</div>
</Card>
{/* SIDEBAR */}
@ -126,21 +111,19 @@ export default function CategoriesDetailForm() {
{/* Creator */}
<div>
<Label>Created By</Label>
<Input readOnly value={detail.createdByFullname || "-"} />
{/* <Input
<Input
type="text"
value={detail.createdByName || detail.createdById}
readOnly
/> */}
/>
</div>
{/* Status */}
<div>
<Label>Status</Label>
<p className="text-sm text-green-500">
{/* {detail.isPublish ? "Published" : "Draft"} |{" "} */}
{detail.isActive ? "* Active" : "- Inactive"}
<p className="text-sm text-slate-600">
{detail.isPublish ? "Published" : "Draft"} |{" "}
{detail.isActive ? "Active" : "Inactive"}
</p>
</div>

View File

@ -31,7 +31,6 @@ type CategoryDetail = {
isActive: boolean;
createdAt: string;
updatedAt: string;
createdByFullname: string;
};
export default function CategoriesUpdateForm() {
@ -61,7 +60,7 @@ export default function CategoriesUpdateForm() {
}, [id]);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
setFormData((prev) => (prev ? { ...prev, [name]: value } : prev));
@ -95,7 +94,7 @@ export default function CategoriesUpdateForm() {
MySwal.fire(
"Error",
res?.message || "Gagal mengunggah thumbnail",
"error",
"error"
);
return null;
}
@ -123,28 +122,11 @@ export default function CategoriesUpdateForm() {
// 🔹 hanya kirim data mandatory
const payload = {
id: formData.id,
title: formData.title,
description: formData.description,
statusId: formData.statusId,
// ⬇️ FIELD KRUSIAL (WAJIB)
isActive: true,
is_active: true,
isPublish: formData.isPublish,
publishedAt: formData.publishedAt,
parentId: formData.parentId,
slug: formData.slug,
createdById: formData.createdById,
description: formData.description || "",
statusId: formData.statusId || 1,
title: formData.title || "",
};
// const payload = {
// id: formData.id,
// description: formData.description || "",
// statusId: formData.statusId || 1,
// title: formData.title || "",
// };
console.log("UPDATE PAYLOAD:", payload);
const res = await updateArticleCategory(Number(id), payload);
if (!res?.error) {
@ -204,7 +186,7 @@ export default function CategoriesUpdateForm() {
</div>
{/* Thumbnail Upload */}
{/* <div className="space-y-2 py-3">
<div className="space-y-2 py-3">
<Label>Thumbnail</Label>
{thumbnailPreview && (
<img
@ -221,35 +203,28 @@ export default function CategoriesUpdateForm() {
{isUploading && (
<p className="text-sm text-blue-500 mt-1">Mengunggah...</p>
)}
</div> */}
</div>
{/* Status */}
<div className="flex items-center gap-4 py-3">
<div className="flex items-center gap-2">
{/* <Checkbox
<Checkbox
checked={formData.isActive}
onCheckedChange={(checked) =>
handleCheckboxChange("isActive", Boolean(checked))
}
/> */}
{/* <input
type="checkbox"
checked={formData.isActive}
onChange={(e) =>
handleCheckboxChange("isActive", e.target.checked)
}
/>
<Label>Active</Label> */}
<Label>Active</Label>
</div>
</div>
<button
<Button
type="submit"
className="w-full mt-4 bg-[#1f2937] text-white py-2 rounded-xl hover:bg-slate-500"
className="w-full mt-4 bg-green-600 text-white"
disabled={isSubmitting || isUploading}
>
{isSubmitting ? "Menyimpan..." : "Simpan Perubahan"}
</button>
</Button>
</Card>
{/* SIDEBAR */}
@ -258,9 +233,8 @@ export default function CategoriesUpdateForm() {
<div>
<Label>Created By</Label>
<Input
disabled
type="text"
value={formData.createdByFullname}
value={formData.createdByName || formData.createdById}
readOnly
/>
</div>

View File

@ -3,28 +3,13 @@ import React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
interface FormFieldProps {
label: string;
name: string;
type?:
| "text"
| "email"
| "password"
| "number"
| "tel"
| "url"
| "textarea"
| "select"
| "checkbox";
type?: "text" | "email" | "password" | "number" | "tel" | "url" | "textarea" | "select" | "checkbox";
placeholder?: string;
value: any;
onChange: (value: any) => void;
@ -83,9 +68,7 @@ export const FormField: React.FC<FormFieldProps> = ({
onValueChange={(val) => onChange(val)}
disabled={disabled}
>
<SelectTrigger
className={`${hasError ? "border-red-500" : ""} ${className}`}
>
<SelectTrigger className={`${hasError ? "border-red-500" : ""} ${className}`}>
<SelectValue placeholder={placeholder || `Select ${label}`} />
</SelectTrigger>
<SelectContent>
@ -100,45 +83,20 @@ export const FormField: React.FC<FormFieldProps> = ({
case "checkbox":
return (
<div className="flex items-center gap-3">
<div className="flex items-center space-x-2">
<Checkbox
checked={value}
onCheckedChange={(v) => onChange(!!v)}
className="
h-4 w-4
rounded-full
border border-black
text-white
data-[state=checked]:bg-black
focus-visible:ring-2 focus-visible:ring-black
"
id={fieldId}
checked={!!value}
onCheckedChange={(checked) => onChange(checked)}
disabled={disabled}
className={hasError ? "border-red-500" : ""}
/>
<Label
htmlFor={fieldId}
className="text-sm font-medium leading-none cursor-pointer"
>
<Label htmlFor={fieldId} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{label}
</Label>
</div>
);
// case "checkbox":
// return (
// <div className="flex items-center space-x-2">
// <Checkbox
// id={fieldId}
// checked={!!value}
// onCheckedChange={(checked) => onChange(checked)}
// disabled={disabled}
// className={hasError ? "border-red-500" : ""}
// />
// <Label htmlFor={fieldId} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
// {label}
// </Label>
// </div>
// );
case "number":
return (
<Input
@ -146,9 +104,7 @@ export const FormField: React.FC<FormFieldProps> = ({
type="number"
placeholder={placeholder}
value={value || ""}
onChange={(e) =>
onChange(e.target.value ? Number(e.target.value) : "")
}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : "")}
disabled={disabled}
className={`${hasError ? "border-red-500" : ""} ${className}`}
min={min}
@ -180,12 +136,16 @@ export const FormField: React.FC<FormFieldProps> = ({
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
)}
{renderField()}
{helpText && <p className="text-xs text-gray-500">{helpText}</p>}
{hasError && <p className="text-red-500 text-xs">{error}</p>}
{helpText && (
<p className="text-xs text-gray-500">{helpText}</p>
)}
{hasError && (
<p className="text-red-500 text-xs">{error}</p>
)}
</div>
);
};

View File

@ -48,7 +48,6 @@ import dynamic from "next/dynamic";
import { formatDateToIndonesian } from "@/utils/globals";
import ApprovalHistoryModal from "@/components/modal/approval-history-modal";
import { listArticleCategories } from "@/service/content";
import { AccessGuard } from "@/components/access-guard";
const videoSchema = z.object({
title: z.string().min(1, { message: "Judul diperlukan" }),
@ -94,7 +93,6 @@ type Detail = {
createdAt: string;
updatedAt: string;
files: FileType[] | null;
publishedFor?: string | null;
categories: {
id: number;
title: string;
@ -162,16 +160,6 @@ export default function FormVideoDetail() {
fetchCategories();
}, []);
useEffect(() => {
if (!detail?.publishedFor) return;
const publisherIds = detail.publishedFor
.split(",")
.map((id) => Number(id.trim()));
setSelectedPublishers(publisherIds);
}, [detail]);
useEffect(() => {
async function fetchDetail() {
if (!id) return;
@ -186,22 +174,9 @@ export default function FormVideoDetail() {
uploadedById: details?.createdById,
files: details?.files || [],
thumbnailUrl: details?.thumbnailUrl || details?.thumbnail || "",
publishedFor: details?.publishedFor,
};
setDetail(mappedDetail);
setFiles(details?.files || []);
// 🔥 Parse publish target seperti content image
const rawPublished =
details?.published_for || details?.publishedFor || "";
if (rawPublished) {
const publisherIds = rawPublished
.split(",")
.map((id: string) => Number(id.trim()));
setSelectedPublishers(publisherIds);
}
const approvals = await getDataApprovalByMediaUpload(mappedDetail.id);
setApproval(approvals?.data?.data);
} catch (err) {
@ -223,7 +198,7 @@ export default function FormVideoDetail() {
const handleCheckboxChange = (id: number) => {
setSelectedPublishers((prev) =>
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
);
};
@ -259,15 +234,6 @@ export default function FormVideoDetail() {
if (!detail) return <div className="p-10 text-gray-500">Memuat data...</div>;
const isPending = Number(detail?.statusId) === 1;
const isApproved = Number(detail?.statusId) === 2;
const isRejected = Number(detail?.statusId) === 4;
const isCreator =
Number(detail?.createdById || detail?.uploadedById) === Number(userId);
const hasApproval = approval != null;
return (
<form>
<div className="flex flex-col lg:flex-row gap-10 border rounded-lg">
@ -359,7 +325,7 @@ export default function FormVideoDetail() {
</div>
<div className="mt-3 px-3 space-y-2">
<Label>Thumbnail</Label>
<Label>Preview</Label>
<Card className="mt-2 w-fit">
<img
src={detail.thumbnailUrl || detail.thumbnailLink}
@ -384,64 +350,10 @@ export default function FormVideoDetail() {
</div>
<div className="px-3 py-3">
<div className="flex flex-col gap-2 space-y-2">
<Label>Publish Target</Label>
{/* UMUM = 4 */}
<div className="flex gap-2 items-center">
<input
type="checkbox"
id="4"
value="4"
checked={selectedPublishers.includes(4)}
readOnly
className="h-4 w-4 border border-gray-300 rounded"
/>
<Label htmlFor="4">UMUM</Label>
</div>
{/* JOURNALIS = 5 */}
<div className="flex gap-2 items-center">
<input
type="checkbox"
id="5"
value="5"
checked={selectedPublishers.includes(5)}
readOnly
className="h-4 w-4 border border-gray-300 rounded"
/>
<Label htmlFor="5">JOURNALIS</Label>
</div>
</div>
{/* <div className="flex flex-col gap-2 space-y-2">
<Label>Publish Target</Label>
<div className="flex gap-2 items-center">
<Checkbox
id="4"
checked={selectedPublishers.includes(5)}
disabled
/>
<Label htmlFor="4">UMUM</Label>
</div>
<div className="flex gap-2 items-center">
<Checkbox
id="5"
checked={selectedPublishers.includes(6)}
disabled
/>
<Label htmlFor="5">JOURNALIS</Label>
</div>
</div> */}
</div>
{/* <div className="px-3 py-3 gap-2">
<Label>Publish Target</Label>
{[5, 6].map((target) => (
<div key={target} className="flex items-center gap-2">
<input
type="checkbox"
<Checkbox
id={String(target)}
checked={selectedPublishers.includes(target)}
onChange={() => handleCheckboxChange(target)}
@ -451,7 +363,7 @@ export default function FormVideoDetail() {
</Label>
</div>
))}
</div> */}
</div>
<div className="px-3 py-3 border mx-3">
<p>Information:</p>
@ -475,46 +387,7 @@ export default function FormVideoDetail() {
</div>
)}
{/* {isPending &&
(Number(detail.needApprovalFromLevel || 0) ===
Number(userLevelId) ||
detail.isPublish === false) &&
Number(detail.uploadedById || detail.createdById) !==
Number(userId) ? ( */}
{isPending && (
<AccessGuard action="approve">
<div className="flex flex-col gap-2 p-3">
<Button
onClick={() => actionApproval("2")}
color="primary"
type="button"
>
<Icon icon="fa:check" className="mr-3" /> Accept
</Button>
<Button
onClick={() => actionApproval("3")}
className="bg-orange-400 hover:bg-orange-300"
type="button"
>
<Icon icon="fa:comment-o" className="mr-3" /> Revision
</Button>
<Button
onClick={() => actionApproval("4")}
color="destructive"
type="button"
>
<Icon icon="fa:times" className="mr-3" /> Reject
</Button>
</div>
</AccessGuard>
)}
{/* ) : null} */}
{/* {(Number(detail.needApprovalFromLevel || 0) ==
{(Number(detail.needApprovalFromLevel || 0) ==
Number(userLevelId) ||
(detail.isPublish === false && detail.statusId == 1)) &&
Number(detail.uploadedById || detail.createdById) !=
@ -542,7 +415,7 @@ export default function FormVideoDetail() {
<Icon icon="fa:times" className="mr-3" /> Reject
</Button>
</div>
) : null} */}
) : null}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-h-[600px] overflow-y-auto">

View File

@ -362,7 +362,7 @@ const CustomEditor = dynamic(
() => {
return import("@/components/editor/custom-editor");
},
{ ssr: false },
{ ssr: false }
);
interface FileWithPreview extends File {
@ -412,11 +412,11 @@ export default function FormVideo() {
const [isGeneratedArticle, setIsGeneratedArticle] = useState(false);
const [articleBody, setArticleBody] = useState<string>("");
const [selectedArticleId, setSelectedArticleId] = useState<string | null>(
null,
null
);
const [selectedMainKeyword, setSelectedMainKeyword] = useState("");
const [publishedForError, setPublishedForError] = useState<string | null>(
null,
null
);
const userId = Cookies.get("userId");
const [selectedSize, setSelectedSize] = useState("");
@ -478,7 +478,7 @@ export default function FormVideo() {
}
const filesWithPreview = acceptedFiles.map((file) =>
Object.assign(file, { preview: URL.createObjectURL(file) }),
Object.assign(file, { preview: URL.createObjectURL(file) })
);
setFiles((prev) => {
@ -503,12 +503,12 @@ export default function FormVideo() {
files.every(
(file: File) =>
["video/mp4", "video/mov", "video/avi"].includes(file.type) &&
file.size <= 100 * 1024 * 1024,
file.size <= 100 * 1024 * 1024
),
{
message:
"Hanya file .mp4, .mov, .avi, maksimal 100MB yang diperbolehkan.",
},
}
),
categoryId: z.string().min(1, { message: "Kategori wajib dipilih." }),
tags: z
@ -722,7 +722,7 @@ export default function FormVideo() {
const articleData = await waitForStatusUpdate();
const cleanArticleBody = articleData?.articleBody?.replace(
/<img[^>]*>/g,
"",
""
);
const articleImagesData = articleData?.imagesUrl?.split(",");
setArticleBody(cleanArticleBody || "");
@ -798,7 +798,7 @@ export default function FormVideo() {
if (scheduleId && scheduleType === "3") {
const findCategory = resCategory.find((o) =>
o.name.toLowerCase().includes("pers rilis"),
o.name.toLowerCase().includes("pers rilis")
);
if (findCategory) {
@ -829,7 +829,7 @@ export default function FormVideo() {
setPublishedFor(
options
.filter((opt: any) => opt.id !== "all")
.map((opt: any) => opt.id),
.map((opt: any) => opt.id)
);
}
} else {
@ -866,8 +866,8 @@ export default function FormVideo() {
const finalDescription = isSwitchOn
? data.description
: selectedFileType === "rewrite"
? data.rewriteDescription
: data.descriptionOri;
? data.rewriteDescription
: data.descriptionOri;
if (!finalDescription?.trim()) {
MySwal.fire("Error", "Deskripsi tidak boleh kosong.", "error");
@ -930,11 +930,12 @@ export default function FormVideo() {
}
if (id == undefined) {
// New Articles API request data structure
const articleData: CreateArticleData = {
aiArticleId: 0,
aiArticleId: 0, // default 0
categoryIds: selectedCategory.toString(),
createdAt: formatDateForBackend(new Date()),
createdById: Number(userId),
createdAt: formatDateForBackend(new Date()), // ✅ format sesuai backend
createdById: Number(userId), // isi dengan userId valid
description: htmlToString(finalDescription),
htmlDescription: finalDescription,
isDraft: true,
@ -947,9 +948,9 @@ export default function FormVideo() {
tags: finalTags,
title: finalTitle,
typeId: 2,
publishedFor: data.publishedFor.join(","),
};
// Use new Articles API
const response = await createArticle(articleData);
console.log("Article Data Submitted:", articleData);
console.log("Article API Response:", response);
@ -958,7 +959,7 @@ export default function FormVideo() {
MySwal.fire(
"Error",
response.message || "Failed to create article",
"error",
"error"
);
return false;
}
@ -986,7 +987,7 @@ export default function FormVideo() {
MySwal.fire(
"Error",
uploadResponse.message || "Failed to upload files",
"error",
"error"
);
return false;
}
@ -1002,17 +1003,17 @@ export default function FormVideo() {
try {
const thumbnailResponse = await uploadArticleThumbnail(
articleId,
thumbnailFormData,
thumbnailFormData
);
if (thumbnailResponse?.error) {
console.warn(
"Thumbnail upload failed:",
thumbnailResponse.message,
thumbnailResponse.message
);
} else {
console.log(
"Thumbnail uploaded successfully:",
thumbnailResponse,
thumbnailResponse
);
}
} catch (thumbnailError) {
@ -1024,7 +1025,7 @@ export default function FormVideo() {
MySwal.fire(
"Error",
"Failed to upload files. Please try again.",
"error",
"error"
);
return false;
}
@ -1075,7 +1076,7 @@ export default function FormVideo() {
idx: number,
id: string,
file: any,
duration: string,
duration: string
) {
console.log(idx, id, file, duration);
@ -1111,7 +1112,7 @@ export default function FormVideo() {
onChunkComplete: (
chunkSize: any,
bytesAccepted: any,
bytesTotal: any,
bytesTotal: any
) => {
const uploadPersen = Math.floor((bytesAccepted / bytesTotal) * 100);
progressInfo[idx].percentage = uploadPersen;
@ -1355,43 +1356,14 @@ export default function FormVideo() {
<div className="flex flex-row items-center gap-3 py-2">
<Label>Ai Assistance</Label>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={isSwitchOn}
onChange={(e) => setIsSwitchOn(e.target.checked)}
className="sr-only peer"
/>
<div
className="
w-11 h-6
bg-gray-300
rounded-full
peer
peer-checked:bg-blue-600
transition-colors
after:content-['']
after:absolute
after:top-[2px]
after:left-[2px]
after:bg-white
after:rounded-full
after:h-5
after:w-5
after:transition-transform
peer-checked:after:translate-x-5
"
/>
</label>
{/* <Switch
<Switch
defaultChecked={isSwitchOn}
color="primary"
id="c2"
onCheckedChange={(checked: boolean) =>
setIsSwitchOn(checked)
}
/> */}
/>
</div>
</div>
{isSwitchOn && (
@ -1650,13 +1622,14 @@ export default function FormVideo() {
<p className="text-sm font-semibold">Content Rewrite</p>
<div className="my-2">
<button
<Button
size="sm"
type="button"
onClick={handleRewriteClick}
className="bg-blue-500 text-white py-2 px-4 rounded"
>
Content Rewrite
</button>
</Button>
</div>
{showRewriteEditor && (
@ -1841,7 +1814,7 @@ export default function FormVideo() {
type="button"
onClick={() => {
const updatedTags = field.value.filter(
(_, i) => i !== index,
(_, i) => i !== index
);
field.onChange(updatedTags);
}}
@ -1879,19 +1852,19 @@ export default function FormVideo() {
? isAllChecked
: field.value.includes(option.id);
const handleChange = (checked: boolean) => {
const handleChange = () => {
let updated: string[] = [];
if (option.id === "all") {
updated = checked
? options
updated = isAllChecked
? []
: options
.filter((opt: any) => opt.id !== "all")
.map((opt: any) => opt.id)
: [];
.map((opt: any) => opt.id);
} else {
updated = checked
? [...field.value, option.id]
: field.value.filter((val) => val !== option.id);
updated = isChecked
? field.value.filter((val) => val !== option.id)
: [...field.value, option.id];
if (isAllChecked && option.id !== "all") {
updated = updated.filter((val) => val !== "all");
@ -1902,47 +1875,17 @@ export default function FormVideo() {
setPublishedFor(updated);
};
// const handleChange = () => {
// let updated: string[] = [];
// if (option.id === "all") {
// updated = isAllChecked
// ? []
// : options
// .filter((opt: any) => opt.id !== "all")
// .map((opt: any) => opt.id);
// } else {
// updated = isChecked
// ? field.value.filter((val) => val !== option.id)
// : [...field.value, option.id];
// if (isAllChecked && option.id !== "all") {
// updated = updated.filter((val) => val !== "all");
// }
// }
// field.onChange(updated);
// setPublishedFor(updated);
// };
return (
<div
key={option.id}
className="flex gap-2 items-center"
>
<input
type="checkbox"
id={option.id}
checked={isChecked}
onChange={(e) => handleChange(e.target.checked)}
className="h-4 w-4 border border-gray-300 rounded text-blue-600 focus:ring-blue-500"
/>
{/* <Checkbox
<Checkbox
id={option.id}
checked={isChecked}
onCheckedChange={handleChange}
className="border"
/> */}
/>
<Label htmlFor={option.id}>{option.label}</Label>
</div>
);

View File

@ -43,7 +43,7 @@ import { TimeIcon, TimesIcon } from "@/components/icons";
const CustomEditor = dynamic(
() => import("@/components/editor/custom-editor"),
{ ssr: false },
{ ssr: false }
);
const videoSchema = z.object({
@ -59,12 +59,12 @@ const videoSchema = z.object({
files.every(
(file: File) =>
["video/mp4", "video/mov", "video/avi"].includes(file.type) &&
file.size <= 100 * 1024 * 1024,
file.size <= 100 * 1024 * 1024
),
{
message:
"Hanya file .mp4, .mov, .avi, maksimal 100MB yang diperbolehkan.",
},
}
),
publishedFor: z
.array(z.string())
@ -129,7 +129,7 @@ export default function FormVideoUpdate() {
}
const filesWithPreview = acceptedFiles.map((file) =>
Object.assign(file, { preview: URL.createObjectURL(file) }),
Object.assign(file, { preview: URL.createObjectURL(file) })
);
setFiles((prev) => {
@ -177,13 +177,7 @@ export default function FormVideoUpdate() {
setSelectedCategory(String(detailData.categories[0].id));
setTags(detailData.tags?.split(",").map((t: string) => t.trim()) || []);
// setPublishedFor(detailData.publishedFor?.split(",") || []);
const publishTargets = detailData?.publishedFor
? detailData.publishedFor.split(",")
: [];
setPublishedFor(publishTargets);
setValue("publishedFor", publishTargets);
setPublishedFor(detailData.publishedFor?.split(",") || []);
} catch (err) {
close();
console.error("❌ Error loading detail:", err);
@ -207,7 +201,7 @@ export default function FormVideoUpdate() {
const handleCheckboxChange = (value: string) => {
setPublishedFor((prev) =>
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value],
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]
);
};
@ -216,32 +210,20 @@ export default function FormVideoUpdate() {
loading();
const payload = {
aiArticleId: detail.aiArticleId,
categoryIds: selectedCategory,
aiArticleId: detail?.aiArticleId ?? "",
categoryIds: selectedCategory ?? "",
createdById: detail?.createdById ?? "",
description: htmlToString(data.description),
htmlDescription: data.description,
isDraft: false,
isPublish: true,
slug: detail?.slug ?? data.title.toLowerCase().replace(/\s+/g, "-"),
statusId: detail?.statusId ?? 1,
tags: tags.join(","),
title: data.title,
typeId: detail.typeId,
slug: detail?.slug ?? data.title.toLowerCase().replace(/\s+/g, "-"),
publishedFor: data.publishedFor.join(","),
typeId: detail?.typeId ?? 2,
};
// const payload = {
// aiArticleId: detail?.aiArticleId ?? "",
// categoryIds: selectedCategory ?? "",
// createdById: detail?.createdById ?? "",
// description: htmlToString(data.description),
// htmlDescription: data.description,
// isDraft: false,
// isPublish: true,
// slug: detail?.slug ?? data.title.toLowerCase().replace(/\s+/g, "-"),
// statusId: detail?.statusId ?? 1,
// tags: tags.join(","),
// title: data.title,
// typeId: detail?.typeId ?? 2,
// };
console.log("📤 Payload Update:", payload);
const res = await updateArticle(Number(id), payload);
@ -289,17 +271,6 @@ export default function FormVideoUpdate() {
if (!detail) return <p className="p-5 text-center">Memuat data...</p>;
const getVideoSrc = (url?: string) => {
if (!url) return "";
// kalau sudah absolute
if (url.startsWith("http")) return url;
// kalau backend kirim relative path
const baseUrl = process.env.NEXT_PUBLIC_API_URL || "";
return `${baseUrl}${url}`;
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col lg:flex-row gap-10 border rounded-lg">
@ -381,24 +352,21 @@ export default function FormVideoUpdate() {
{detailFiles.map((file) => (
<div key={file.id} className="flex flex-row items-center gap-4">
<video
className="object-contain w-[300px] h-[200px] rounded border"
src={getVideoSrc(file.fileUrl)}
className="object-contain w-[300px] h-[200px]"
src={file.fileUrl}
controls
preload="metadata"
title={file.fileName}
/>
<p>{file.fileName}</p>
<button
type="button"
<a
className="text-destructive"
onClick={() => handleDeleteFile(file.id)}
>
<TimesIcon />
</button>
</a>
</div>
))}
{files?.map((file) => (
<div
key={file.name}
@ -431,7 +399,7 @@ export default function FormVideoUpdate() {
<Controller
control={control}
name="creatorName"
render={({ field }) => <Input {...field} />}
render={({ field }) => <Input {...field} />}
/>
<div className="mt-3 space-y-2">
@ -487,68 +455,6 @@ export default function FormVideoUpdate() {
<div className="mt-4 space-y-2">
<Label>Publish Target</Label>
<Controller
control={control}
name="publishedFor"
render={({ field }) => {
const isAllChecked =
field.value?.length ===
options.filter((opt) => opt.id !== "all").length;
return (
<div className="flex flex-col gap-3">
{options.map((option) => {
const isChecked =
option.id === "all"
? isAllChecked
: field.value?.includes(option.id);
const handleChange = (checked: boolean) => {
let updated: string[] = [];
if (option.id === "all") {
updated = checked
? options
.filter((opt) => opt.id !== "all")
.map((opt) => opt.id)
: [];
} else {
updated = checked
? [...(field.value || []), option.id]
: field.value?.filter(
(val) => val !== option.id,
) || [];
}
field.onChange(updated);
setPublishedFor(updated);
};
return (
<div
key={option.id}
className="flex gap-2 items-center"
>
<input
type="checkbox"
id={option.id}
checked={isChecked}
onChange={(e) => handleChange(e.target.checked)}
/>
<Label htmlFor={option.id}>{option.label}</Label>
</div>
);
})}
{errors.publishedFor && (
<p className="text-red-500 text-sm">
{errors.publishedFor.message}
</p>
)}
</div>
);
}}
/>
{/* <Controller
control={control}
name="publishedFor"
render={({ field }) => (
@ -564,19 +470,23 @@ export default function FormVideoUpdate() {
? isAllChecked
: field.value.includes(option.id);
const handleChange = (checked: boolean) => {
const handleChange = () => {
let updated: string[] = [];
if (option.id === "all") {
updated = checked
? options
updated = isAllChecked
? []
: options
.filter((opt: any) => opt.id !== "all")
.map((opt: any) => opt.id)
: [];
.map((opt: any) => opt.id);
} else {
updated = checked
? [...field.value, option.id]
: field.value.filter((val) => val !== option.id);
updated = isChecked
? field.value.filter((val) => val !== option.id)
: [...field.value, option.id];
if (isAllChecked && option.id !== "all") {
updated = updated.filter((val) => val !== "all");
}
}
field.onChange(updated);
@ -588,11 +498,10 @@ export default function FormVideoUpdate() {
key={option.id}
className="flex gap-2 items-center"
>
<input
type="checkbox"
<Checkbox
id={option.id}
checked={isChecked}
onChange={(e) => handleChange(e.target.checked)}
onCheckedChange={handleChange}
className="border"
/>
<Label htmlFor={option.id}>{option.label}</Label>
@ -608,23 +517,18 @@ export default function FormVideoUpdate() {
</div>
</div>
)}
/> */}
/>
</div>
<div className="flex justify-end gap-3 mt-5">
<button
type="submit"
className="border border-black hover:bg-black hover:text-white rounded-lg px-8 py-4"
>
Update
</button>
<button
<Button type="submit" className="border rounded-lg">Update</Button>
<Button
type="button"
className="border border-black hover:bg-black hover:text-white rounded-lg px-8 py-4"
variant="outline"
onClick={() => router.back()}
>
Cancel
</button>
</Button>
</div>
</Card>
</div>

View File

@ -66,7 +66,6 @@ import ApprovalHistoryModal from "@/components/modal/approval-history-modal";
import { useDropzone } from "react-dropzone";
import AudioPlayer from "@/components/audio-player";
import { listArticleCategories } from "@/service/content";
import { AccessGuard } from "@/components/access-guard";
const imageSchema = z.object({
title: z.string().min(1, { message: "Judul diperlukan" }),
@ -84,75 +83,36 @@ type Category = {
type FileType = {
id: number;
secondaryUrl: string;
thumbnailFileUrl: string;
fileName: string;
secondaryUrl?: string | null;
fileUrl?: string | null;
preview?: string;
};
type Detail = {
id: number;
id: string;
title: string;
description: string;
htmlDescription: string;
slug: string;
categoryId: number;
categoryName: string;
typeId: number;
tags: string;
thumbnailUrl: string;
pageUrl: string | null;
createdById: number;
createdByName: string;
shareCount: number;
viewCount: number;
commentCount: number;
aiArticleId: number | null;
oldId: number;
statusId: number;
isBanner: boolean;
isPublish: boolean;
publishedAt: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
files: FileType[] | null;
publishedFor?: string | null;
categories: {
id: number;
title: string;
description: string;
thumbnailUrl: string;
slug: string | null;
tags: string[];
thumbnailPath: string | null;
parentId: number;
oldCategoryId: number | null;
createdById: number;
statusId: number;
isPublish: boolean;
publishedAt: string | null;
isEnabled: boolean | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}[];
// Legacy fields for backward compatibility
category?: {
category: {
id: number;
name: string;
};
creatorName?: string;
thumbnailLink?: string;
statusName?: string;
needApprovalFromLevel?: number;
uploadedById?: number;
categoryName: string;
creatorName: string;
thumbnailLink: string;
tags: string;
statusName: string;
isPublish: boolean;
needApprovalFromLevel: number;
files: FileType[];
uploadedById: number;
};
const ViewEditor = dynamic(
() => {
return import("@/components/editor/view-editor");
},
{ ssr: false },
{ ssr: false }
);
export default function FormAudioDetail() {
@ -257,19 +217,9 @@ export default function FormAudioDetail() {
}
}, [userLevelId, roleId]);
useEffect(() => {
if (!detail?.publishedFor) return;
const publisherIds = detail.publishedFor
.split(",")
.map((id: any) => Number(id.trim()));
setSelectedPublishers(publisherIds);
}, [detail]);
const handleCheckboxChange = (id: number) => {
setSelectedPublishers((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id],
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]
);
};
@ -299,7 +249,9 @@ export default function FormAudioDetail() {
if (id) {
const response = await getArticleDetail(Number(id));
const details = response?.data?.data;
console.log("detail", details);
setFiles(details?.files);
console.log("ISI FILES:", details?.files);
setSelectedCategory(String(details.categories[0].id));
setDetail(details);
@ -314,34 +266,14 @@ export default function FormAudioDetail() {
});
setupPlacementCheck(details?.files?.length);
// 🔥 Parse publish target seperti content image
const rawPublished =
details?.published_for || details?.publishedFor || "";
if (rawPublished) {
const publisherIds = rawPublished
.split(",")
.map((id: string) => Number(id.trim()));
setSelectedPublishers(publisherIds);
}
if (details?.publishedForObject) {
const publisherIds = details?.publishedForObject.map(
(obj: any) => obj.id,
(obj: any) => obj.id
);
setSelectedPublishers(publisherIds);
}
const categoryId =
details?.category?.id ||
details?.categoryId ||
details?.categories?.[0]?.id;
if (categoryId) {
setSelectedTarget(String(categoryId));
setSelectedCategory(String(categoryId));
}
setSelectedTarget(String(details.category.id));
const filesData = details?.files || [];
// const audioFiles = filesData.filter(
@ -450,7 +382,7 @@ export default function FormAudioDetail() {
const setupPlacement = (
index: number,
placement: string,
checked: boolean,
checked: boolean
) => {
let temp = [...filePlacements];
if (checked) {
@ -484,7 +416,7 @@ export default function FormAudioDetail() {
type: string,
url: string,
names: string,
format: string,
format: string
) => {
console.log("Test 3 :", type, url, names, format);
setMain({
@ -516,8 +448,6 @@ export default function FormAudioDetail() {
});
};
const isPending = Number(detail?.statusId) === 1;
return (
<form>
{detail !== undefined ? (
@ -587,47 +517,6 @@ export default function FormAudioDetail() {
<div className="w-full">
<Label className="text-xl space-y-2">File Media</Label>
<div className="space-y-4 mt-4">
{files.length === 0 ? (
<p className="text-center text-gray-500">
Tidak ada file media
</p>
) : (
files.map((file, idx) => {
const audioSrc = file.secondaryUrl || file.fileUrl;
return (
<div
key={idx}
className="flex flex-col gap-2 border p-2 rounded-md"
>
<p className="text-sm font-medium truncate">
{file.fileName}
</p>
{audioSrc ? (
<audio
controls
src={audioSrc}
className="w-full rounded"
>
Browser tidak mendukung audio.
</audio>
) : (
<p className="text-xs text-red-500">
Audio source tidak tersedia
</p>
)}
</div>
);
})
)}
</div>
</div>
{/* <div className="w-full">
<Label className="text-xl space-y-2">File Media</Label>
<div className="w-full">
{files.length === 0 ? (
<p className="text-center text-gray-500">
@ -643,7 +532,7 @@ export default function FormAudioDetail() {
))
)}
</div>
</div> */}
</div>
</div>
</div>
</Card>
@ -671,58 +560,6 @@ export default function FormAudioDetail() {
</div>
</div>
<div className="px-3 py-3">
<div className="flex flex-col gap-2 space-y-2">
<Label>Publish Target</Label>
{/* UMUM = 4 */}
<div className="flex gap-2 items-center">
<input
type="checkbox"
id="4"
value="4"
checked={selectedPublishers.includes(4)}
readOnly
className="h-4 w-4 border border-gray-300 rounded"
/>
<Label htmlFor="4">UMUM</Label>
</div>
{/* JOURNALIS = 5 */}
<div className="flex gap-2 items-center">
<input
type="checkbox"
id="5"
value="5"
checked={selectedPublishers.includes(5)}
readOnly
className="h-4 w-4 border border-gray-300 rounded"
/>
<Label htmlFor="5">JOURNALIS</Label>
</div>
</div>
{/* <div className="flex flex-col gap-2 space-y-2">
<Label>Publish Target</Label>
<div className="flex gap-2 items-center">
<Checkbox
id="4"
checked={selectedPublishers.includes(5)}
disabled
/>
<Label htmlFor="4">UMUM</Label>
</div>
<div className="flex gap-2 items-center">
<Checkbox
id="5"
checked={selectedPublishers.includes(6)}
disabled
/>
<Label htmlFor="5">JOURNALIS</Label>
</div>
</div> */}
</div>
{/* <div className="px-3 py-3">
<div className="flex flex-col gap-2 space-y-2">
<Label>Publish Target</Label>
<div className="flex gap-2 items-center">
@ -744,7 +581,7 @@ export default function FormAudioDetail() {
<Label htmlFor="6">JOURNALIS</Label>
</div>
</div>
</div> */}
</div>
<SuggestionModal
id={Number(id)}
numberOfSuggestion={detail?.numberOfSuggestion}
@ -810,7 +647,7 @@ export default function FormAudioDetail() {
id="terms"
value="all"
checked={filePlacements[index]?.includes(
"all",
"all"
)}
onCheckedChange={(e) =>
setupPlacement(index, "all", Boolean(e))
@ -827,7 +664,7 @@ export default function FormAudioDetail() {
<Checkbox
id="terms"
checked={filePlacements[index]?.includes(
"mabes",
"mabes"
)}
onCheckedChange={(e) =>
setupPlacement(index, "mabes", Boolean(e))
@ -844,7 +681,7 @@ export default function FormAudioDetail() {
<Checkbox
id="terms"
checked={filePlacements[index]?.includes(
"polda",
"polda"
)}
onCheckedChange={(e) =>
setupPlacement(index, "polda", Boolean(e))
@ -862,13 +699,13 @@ export default function FormAudioDetail() {
<Checkbox
id="terms"
checked={filePlacements[index]?.includes(
"international",
"international"
)}
onCheckedChange={(e) =>
setupPlacement(
index,
"international",
Boolean(e),
Boolean(e)
)
}
/>
@ -984,34 +821,30 @@ export default function FormAudioDetail() {
Number(detail?.uploadedById) == Number(userId) ? (
""
) : ( */}
{isPending && (
<AccessGuard action="approve">
<div className="flex flex-col gap-2 p-3">
<Button
onClick={() => actionApproval("2")}
color="primary"
type="button"
>
<Icon icon="fa:check" className="mr-3" /> Accept
</Button>
<Button
onClick={() => actionApproval("3")}
className="bg-orange-400 hover:bg-orange-300"
type="button"
>
<Icon icon="fa:comment-o" className="mr-3" /> Revision
</Button>
<Button
onClick={() => actionApproval("4")}
color="destructive"
type="button"
>
<Icon icon="fa:times" className="mr-3" />
Reject
</Button>
</div>
</AccessGuard>
)}
<div className="flex flex-col gap-2 p-3">
<Button
onClick={() => actionApproval("2")}
color="primary"
type="button"
>
<Icon icon="fa:check" className="mr-3" /> Accept
</Button>
<Button
onClick={() => actionApproval("3")}
className="bg-orange-400 hover:bg-orange-300"
type="button"
>
<Icon icon="fa:comment-o" className="mr-3" /> Revision
</Button>
<Button
onClick={() => actionApproval("4")}
color="destructive"
type="button"
>
<Icon icon="fa:times" className="mr-3" />
Reject
</Button>
</div>
{/* )
) : (
""

View File

@ -82,7 +82,7 @@ const CustomEditor = dynamic(
() => {
return import("@/components/editor/custom-editor");
},
{ ssr: false },
{ ssr: false }
);
export default function FormAudio() {
@ -118,7 +118,7 @@ export default function FormAudio() {
const [isGeneratedArticle, setIsGeneratedArticle] = useState(false);
const [articleBody, setArticleBody] = useState<string>("");
const [selectedArticleId, setSelectedArticleId] = useState<string | null>(
null,
null
);
const [selectedMainKeyword, setSelectedMainKeyword] = useState("");
const [selectedSize, setSelectedSize] = useState("");
@ -149,7 +149,7 @@ export default function FormAudio() {
type FileWithPreview = File & { preview: string };
const userId = Cookies.get("userId");
const options: Option[] = [
const options: Option[] = [
{ id: "all", label: "SEMUA" },
{ id: "4", label: "UMUM" },
{ id: "5", label: "JOURNALIS" },
@ -181,7 +181,7 @@ export default function FormAudio() {
}
const filesWithPreview = acceptedFiles.map((file) =>
Object.assign(file, { preview: URL.createObjectURL(file) }),
Object.assign(file, { preview: URL.createObjectURL(file) })
);
setFiles((prevFiles) => [...prevFiles, ...filesWithPreview]);
@ -216,11 +216,11 @@ export default function FormAudio() {
files.every(
(file: File) =>
["audio/mpeg", "audio/wav", "audio/mp3"].includes(file.type) &&
file.size <= 100 * 1024 * 1024,
file.size <= 100 * 1024 * 1024
),
{
message: "Hanya file .mp3, .wav, maksimal 100MB yang diperbolehkan.",
},
}
),
categoryId: z.string().min(1, { message: "Kategori wajib dipilih." }),
tags: z
@ -430,7 +430,7 @@ export default function FormAudio() {
const articleData = await waitForStatusUpdate();
const cleanArticleBody = articleData?.articleBody?.replace(
/<img[^>]*>/g,
"",
""
);
const articleImagesData = articleData?.imagesUrl?.split(",");
setArticleBody(cleanArticleBody || "");
@ -504,7 +504,7 @@ export default function FormAudio() {
if (scheduleId && scheduleType === "3") {
const findCategory = resCategory.find((o) =>
o.name.toLowerCase().includes("pers rilis"),
o.name.toLowerCase().includes("pers rilis")
);
if (findCategory) {
@ -537,7 +537,7 @@ export default function FormAudio() {
setPublishedFor(
options
.filter((opt: any) => opt.id !== "all")
.map((opt: any) => opt.id),
.map((opt: any) => opt.id)
);
}
} else {
@ -569,8 +569,8 @@ export default function FormAudio() {
const finalDescription = isSwitchOn
? data.description
: selectedFileType === "rewrite"
? data.rewriteDescription
: data.descriptionOri;
? data.rewriteDescription
: data.descriptionOri;
if (!finalDescription?.trim()) {
MySwal.fire("Error", "Deskripsi tidak boleh kosong.", "error");
return;
@ -650,7 +650,6 @@ export default function FormAudio() {
tags: finalTags,
title: finalTitle,
typeId: 4,
publishedFor: data.publishedFor.join(","),
};
// Use new Articles API
@ -662,7 +661,7 @@ export default function FormAudio() {
MySwal.fire(
"Error",
response.message || "Failed to create article",
"error",
"error"
);
return false;
}
@ -690,7 +689,7 @@ export default function FormAudio() {
MySwal.fire(
"Error",
uploadResponse.message || "Failed to upload files",
"error",
"error"
);
return false;
}
@ -707,19 +706,19 @@ export default function FormAudio() {
try {
const thumbnailResponse = await uploadArticleThumbnail(
articleId,
thumbnailFormData,
thumbnailFormData
);
if (thumbnailResponse?.error) {
console.warn(
"Thumbnail upload failed:",
thumbnailResponse.message,
thumbnailResponse.message
);
// Don't fail the whole process if thumbnail upload fails
} else {
console.log(
"Thumbnail uploaded successfully:",
thumbnailResponse,
thumbnailResponse
);
}
} catch (thumbnailError) {
@ -732,7 +731,7 @@ export default function FormAudio() {
MySwal.fire(
"Error",
"Failed to upload files. Please try again.",
"error",
"error"
);
return false;
}
@ -775,7 +774,7 @@ export default function FormAudio() {
idx: number,
id: string,
file: any,
duration: string,
duration: string
) {
console.log(idx, id, file, duration);
@ -811,7 +810,7 @@ export default function FormAudio() {
onChunkComplete: (
chunkSize: any,
bytesAccepted: any,
bytesTotal: any,
bytesTotal: any
) => {
const uploadPersen = Math.floor((bytesAccepted / bytesTotal) * 100);
progressInfo[idx].percentage = uploadPersen;
@ -1054,36 +1053,6 @@ export default function FormAudio() {
<div className="flex flex-row items-center gap-3 py-2">
<Label>Ai Assistance</Label>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={isSwitchOn}
onChange={(e) => setIsSwitchOn(e.target.checked)}
className="sr-only peer"
/>
<div
className="
w-11 h-6
bg-gray-300
rounded-full
peer
peer-checked:bg-blue-600
transition-colors
after:content-['']
after:absolute
after:top-[2px]
after:left-[2px]
after:bg-white
after:rounded-full
after:h-5
after:w-5
after:transition-transform
peer-checked:after:translate-x-5
"
/>
</label>
</div>
{/* <div className="flex items-center gap-3">
<Switch
defaultChecked={isSwitchOn}
color="primary"
@ -1092,7 +1061,7 @@ export default function FormAudio() {
setIsSwitchOn(checked)
}
/>
</div> */}
</div>
</div>
{isSwitchOn && (
<div>
@ -1595,19 +1564,19 @@ export default function FormAudio() {
? isAllChecked
: field.value.includes(option.id);
const handleChange = (checked: boolean) => {
const handleChange = () => {
let updated: string[] = [];
if (option.id === "all") {
updated = checked
? options
updated = isAllChecked
? []
: options
.filter((opt: any) => opt.id !== "all")
.map((opt: any) => opt.id)
: [];
.map((opt: any) => opt.id);
} else {
updated = checked
? [...field.value, option.id]
: field.value.filter((val) => val !== option.id);
updated = isChecked
? field.value.filter((val) => val !== option.id)
: [...field.value, option.id];
if (isAllChecked && option.id !== "all") {
updated = updated.filter((val) => val !== "all");
@ -1618,47 +1587,17 @@ export default function FormAudio() {
setPublishedFor(updated);
};
// const handleChange = () => {
// let updated: string[] = [];
// if (option.id === "all") {
// updated = isAllChecked
// ? []
// : options
// .filter((opt: any) => opt.id !== "all")
// .map((opt: any) => opt.id);
// } else {
// updated = isChecked
// ? field.value.filter((val) => val !== option.id)
// : [...field.value, option.id];
// if (isAllChecked && option.id !== "all") {
// updated = updated.filter((val) => val !== "all");
// }
// }
// field.onChange(updated);
// setPublishedFor(updated);
// };
return (
<div
key={option.id}
className="flex gap-2 items-center"
>
<input
type="checkbox"
id={option.id}
checked={isChecked}
onChange={(e) => handleChange(e.target.checked)}
className="h-4 w-4 border border-gray-300 rounded text-blue-600 focus:ring-blue-500"
/>
{/* <Checkbox
<Checkbox
id={option.id}
checked={isChecked}
onCheckedChange={handleChange}
className="border"
/> */}
/>
<Label htmlFor={option.id}>{option.label}</Label>
</div>
);

View File

@ -71,12 +71,12 @@ const audioSchema = z.object({
"audio/x-wav",
"audio/mp4",
"audio/aac",
].includes(file.type) && file.size <= 20 * 1024 * 1024,
].includes(file.type) && file.size <= 20 * 1024 * 1024
),
{
message:
"Hanya file audio (.mp3, .wav, .m4a, .aac) dengan ukuran maksimal 20MB yang diperbolehkan.",
},
}
),
publishedFor: z
@ -118,7 +118,7 @@ const CustomEditor = dynamic(
() => {
return import("@/components/editor/custom-editor");
},
{ ssr: false },
{ ssr: false }
);
export default function FormAudioUpdate() {
@ -232,7 +232,7 @@ export default function FormAudioUpdate() {
const handleEditTag = (index: number, newValue: string) => {
setTags((prevTags) =>
prevTags.map((tag, i) => (i === index ? newValue : tag)),
prevTags.map((tag, i) => (i === index ? newValue : tag))
);
};
@ -250,14 +250,6 @@ export default function FormAudioUpdate() {
setTags(details.tags?.split(",").map((t: string) => t.trim()) || []);
setPublishedFor(details.publishedFor?.split(",") || []);
if (details?.publishedFor) {
const parsed = details.publishedFor
.split(",")
.map((id: string) => id.trim());
setValue("publishedFor", parsed); // 🔥 WAJIB
}
if (details?.files) {
setPrefFiles(details.files);
// setFiles(details.files);
@ -291,7 +283,7 @@ export default function FormAudioUpdate() {
const filesData = details.files || [];
const fileUrls = filesData.map((file: { secondaryUrl: string }) =>
file.secondaryUrl ? file.secondaryUrl : "default-image.jpg",
file.secondaryUrl ? file.secondaryUrl : "default-image.jpg"
);
setDetailThumb(fileUrls);
}
@ -318,7 +310,7 @@ export default function FormAudioUpdate() {
.filter(Boolean);
const allSelected = ["nasional", "wilayah", "internasional"].every((opt) =>
options.includes(opt),
options.includes(opt)
);
return allSelected ? ["all", ...options] : options;
@ -331,12 +323,12 @@ export default function FormAudioUpdate() {
.filter((opt) => opt.id !== "all")
.map((opt) => opt.id);
setPublishedFor(
publishedFor.length === allOptions.length ? [] : allOptions,
publishedFor.length === allOptions.length ? [] : allOptions
);
} else {
// Toggle individual option
setPublishedFor((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id],
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]
);
}
};
@ -360,32 +352,20 @@ export default function FormAudioUpdate() {
// isYoutube: false,
// isInternationalMedia: false,
// };
const payload = {
aiArticleId: detail.aiArticleId,
categoryIds: selectedCategory,
aiArticleId: detail?.aiArticleId ?? "",
categoryIds: selectedCategory ?? "",
createdById: detail?.createdById ?? "",
description: htmlToString(data.description),
htmlDescription: data.description,
isDraft: false,
isPublish: true,
slug: detail?.slug ?? data.title.toLowerCase().replace(/\s+/g, "-"),
statusId: detail?.statusId ?? 1,
tags: tags.join(","),
title: data.title,
typeId: detail.typeId,
slug: detail?.slug ?? data.title.toLowerCase().replace(/\s+/g, "-"),
publishedFor: data.publishedFor.join(","),
typeId: detail?.typeId ?? 4,
};
// const payload = {
// aiArticleId: detail?.aiArticleId ?? "",
// categoryIds: selectedCategory ?? "",
// createdById: detail?.createdById ?? "",
// description: htmlToString(data.description),
// htmlDescription: data.description,
// isDraft: false,
// isPublish: true,
// slug: detail?.slug ?? data.title.toLowerCase().replace(/\s+/g, "-"),
// statusId: detail?.statusId ?? 1,
// tags: tags.join(","),
// title: data.title,
// typeId: detail?.typeId ?? 4,
// };
const res = await updateArticle(Number(id), payload);
if (res?.error) {
@ -441,7 +421,7 @@ export default function FormAudioUpdate() {
idx: number,
id: string,
file: any,
duration: string,
duration: string
) {
console.log(idx, id, file, duration);
@ -478,7 +458,7 @@ export default function FormAudioUpdate() {
onChunkComplete: (
chunkSize: any,
bytesAccepted: any,
bytesTotal: any,
bytesTotal: any
) => {
const uploadPersen = Math.floor((bytesAccepted / bytesTotal) * 100);
progressInfo[idx].percentage = uploadPersen;
@ -569,87 +549,50 @@ export default function FormAudioUpdate() {
setFiles([...filtered]);
};
const getAudioUrl = (file: any) => {
if (file instanceof File) {
return URL.createObjectURL(file);
}
return file.secondaryUrl || file.fileUrl || null;
};
const fileList = files.map((file: any) => {
const audioSrc = getAudioUrl(file);
return (
<div
key={file.id || file.name}
className="flex flex-col gap-2 border p-3 my-6 rounded-md"
>
<p className="text-sm font-medium truncate">{file.name}</p>
{audioSrc && (
<audio controls className="w-full">
<source src={audioSrc} type={file.type || "audio/mpeg"} />
Browser tidak mendukung audio.
</audio>
)}
<Button
type="button"
size="icon"
variant="outline"
className="self-end"
onClick={() => handleRemoveFile(file)}
const fileList = files.map((file: any) => (
<div
key={file.id} // Gunakan ID file sebagai key
className="flex justify-between border px-3.5 py-3 my-6 rounded-md"
>
<div className="flex gap-3 items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 20 20"
>
<Icon icon="tabler:x" className="h-5 w-5" />
</Button>
<path
fill="currentColor"
d="M14.702 2.226A1 1 0 0 1 16 3.18v6.027a5.5 5.5 0 0 0-1-.184V6.18L8 8.368V15.5a2.5 2.5 0 1 1-1-2V5.368a1 1 0 0 1 .702-.955zM8 7.32l7-2.187V3.18L8 5.368zM5.5 14a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3m13.5.5a4.5 4.5 0 1 1-9 0a4.5 4.5 0 0 1 9 0m-2.265-.436l-2.994-1.65a.5.5 0 0 0-.741.438v3.3a.5.5 0 0 0 .741.438l2.994-1.65a.5.5 0 0 0 0-.876"
/>
</svg>{" "}
<div>
<div className="text-sm text-card-foreground">
{file.fileName || file.name}
</div>
<div className="text-xs font-light text-muted-foreground">
{Math.round(file.size / 100) / 10 > 1000 ? (
<>{(Math.round(file.size / 100) / 10000).toFixed(1)}</>
) : (
<>{(Math.round(file.size / 100) / 10).toFixed(1)}</>
)}
{" kb"}
</div>
</div>
</div>
);
});
// const fileList = files.map((file: any) => (
// <div
// key={file.id} // Gunakan ID file sebagai key
// className="flex justify-between border px-3.5 py-3 my-6 rounded-md"
// >
// <div className="flex gap-3 items-center">
// <svg
// xmlns="http://www.w3.org/2000/svg"
// width="48"
// height="48"
// viewBox="0 0 20 20"
// >
// <path
// fill="currentColor"
// d="M14.702 2.226A1 1 0 0 1 16 3.18v6.027a5.5 5.5 0 0 0-1-.184V6.18L8 8.368V15.5a2.5 2.5 0 1 1-1-2V5.368a1 1 0 0 1 .702-.955zM8 7.32l7-2.187V3.18L8 5.368zM5.5 14a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3m13.5.5a4.5 4.5 0 1 1-9 0a4.5 4.5 0 0 1 9 0m-2.265-.436l-2.994-1.65a.5.5 0 0 0-.741.438v3.3a.5.5 0 0 0 .741.438l2.994-1.65a.5.5 0 0 0 0-.876"
// />
// </svg>{" "}
// <div>
// <div className="text-sm text-card-foreground">
// {file.fileName || file.name}
// </div>
// <div className="text-xs font-light text-muted-foreground">
// {Math.round(file.size / 100) / 10 > 1000 ? (
// <>{(Math.round(file.size / 100) / 10000).toFixed(1)}</>
// ) : (
// <>{(Math.round(file.size / 100) / 10).toFixed(1)}</>
// )}
// {" kb"}
// </div>
// </div>
// </div>
// <Button
// type="button"
// size="icon"
// color="destructive"
// variant="outline"
// className="border-none rounded-full"
// onClick={() => handleRemoveFile(file)} // Kirim ID spesifik
// >
// <Icon icon="tabler:x" className="h-5 w-5" />
// </Button>
// </div>
// ));
<Button
type="button"
size="icon"
color="destructive"
variant="outline"
className="border-none rounded-full"
onClick={() => handleRemoveFile(file)} // Kirim ID spesifik
>
<Icon icon="tabler:x" className="h-5 w-5" />
</Button>
</div>
));
const handleCheckboxChangeImage = (fileId: number, value: string) => {
setSelectedOptions((prev: any) => {
@ -671,7 +614,7 @@ export default function FormAudioUpdate() {
// If all individual options are selected, include "all" automatically
const isAllSelected = ["nasional", "wilayah", "internasional"].every(
(opt) => updatedSelections.includes(opt),
(opt) => updatedSelections.includes(opt)
);
return {
...prev,
@ -724,7 +667,7 @@ export default function FormAudioUpdate() {
// Jika berhasil, hapus file dari state lokal
setFiles((prevFiles: any) =>
prevFiles.filter((file: any) => file.id !== id),
prevFiles.filter((file: any) => file.id !== id)
);
success();
} catch (err) {
@ -838,45 +781,9 @@ export default function FormAudioUpdate() {
</Fragment>
) : null}
{prevFiles?.length > 0 &&
prevFiles.map((file: any) => {
const audioSrc = file.secondaryUrl || file.fileUrl;
return (
<div
key={file.id}
className="flex flex-col gap-2 border p-3 my-6 rounded-md"
>
<p className="text-sm font-medium truncate">
{file.fileName}
</p>
{audioSrc ? (
<audio controls className="w-full">
<source src={audioSrc} type="audio/mpeg" />
Browser tidak mendukung audio.
</audio>
) : (
<p className="text-xs text-red-500">
Audio source tidak tersedia
</p>
)}
<Button
size="icon"
variant="outline"
className="self-end"
onClick={() => handleDeleteFile(file.id)}
>
<Icon icon="tabler:x" className="h-5 w-5" />
</Button>
</div>
);
})}
{/* {prevFiles?.length > 0 &&
prevFiles.map((file: any) => (
<div
key={file.id}
key={file.id} // Gunakan ID file sebagai key
className="flex justify-between border px-3.5 py-3 my-6 rounded-md"
>
<div className="flex gap-3 items-center">
@ -905,7 +812,7 @@ export default function FormAudioUpdate() {
) : (
<>
{(Math.round(file.size / 100) / 10).toFixed(
1,
1
)}
</>
)}
@ -924,7 +831,7 @@ export default function FormAudioUpdate() {
<Icon icon="tabler:x" className="h-5 w-5" />
</Button>
</div>
))} */}
))}
{/* {files.length > 0 && (
<div className="mt-4">
<Label className="text-lg font-semibold">
@ -1094,70 +1001,6 @@ export default function FormAudioUpdate() {
<div className="mt-4 space-y-2">
<Label>Publish Target</Label>
<Controller
control={control}
name="publishedFor"
render={({ field }) => {
const isAllChecked =
field.value?.length ===
options.filter((opt) => opt.id !== "all").length;
return (
<div className="flex flex-col gap-3">
{options.map((option) => {
const isChecked =
option.id === "all"
? isAllChecked
: field.value?.includes(option.id);
const handleChange = (checked: boolean) => {
let updated: string[] = [];
if (option.id === "all") {
updated = checked
? options
.filter((opt) => opt.id !== "all")
.map((opt) => opt.id)
: [];
} else {
updated = checked
? [...(field.value || []), option.id]
: field.value?.filter(
(val) => val !== option.id,
) || [];
}
field.onChange(updated);
setPublishedFor(updated);
};
return (
<div
key={option.id}
className="flex gap-2 items-center"
>
<input
type="checkbox"
id={option.id}
checked={isChecked}
onChange={(e) =>
handleChange(e.target.checked)
}
/>
<Label htmlFor={option.id}>{option.name}</Label>
</div>
);
})}
{errors.publishedFor && (
<p className="text-red-500 text-sm">
{errors.publishedFor.message}
</p>
)}
</div>
);
}}
/>
{/* <Controller
control={control}
name="publishedFor"
render={({ field }) => (
@ -1186,13 +1029,13 @@ export default function FormAudioUpdate() {
} else {
updated = isChecked
? field.value.filter(
(val) => val !== option.id,
(val) => val !== option.id
)
: [...field.value, option.id];
if (isAllChecked && option.id !== "all") {
updated = updated.filter(
(val) => val !== "all",
(val) => val !== "all"
);
}
}
@ -1225,7 +1068,7 @@ export default function FormAudioUpdate() {
</div>
</div>
)}
/> */}
/>
</div>
</div>
{/* <div className="px-3 py-3 flex flex-row items-center text-blue-500 gap-2 text-sm">

View File

@ -65,7 +65,6 @@ import ApprovalHistoryModal from "@/components/modal/approval-history-modal";
import FileTextPreview from "../file-preview-text";
import FileTextThumbnail from "../file-text-thumbnail";
import { listArticleCategories } from "@/service/content";
import { AccessGuard } from "@/components/access-guard";
type Option = {
id: string;
@ -94,69 +93,30 @@ type FileType = {
};
type Detail = {
id: number;
id: string;
title: string;
description: string;
htmlDescription: string;
slug: string;
categoryId: number;
categoryName: string;
typeId: number;
tags: string;
thumbnailUrl: string;
pageUrl: string | null;
createdById: number;
createdByName: string;
shareCount: number;
viewCount: number;
commentCount: number;
aiArticleId: number | null;
oldId: number;
statusId: number;
isBanner: boolean;
isPublish: boolean;
publishedAt: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
files: FileType[] | null;
publishedFor?: string | null;
categories: {
id: number;
title: string;
description: string;
thumbnailUrl: string;
slug: string | null;
tags: string[];
thumbnailPath: string | null;
parentId: number;
oldCategoryId: number | null;
createdById: number;
statusId: number;
isPublish: boolean;
publishedAt: string | null;
isEnabled: boolean | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}[];
// Legacy fields for backward compatibility
category?: {
category: {
id: number;
name: string;
};
creatorName?: string;
thumbnailLink?: string;
statusName?: string;
needApprovalFromLevel?: number;
uploadedById?: number;
categoryName: string;
creatorName: string;
thumbnailLink: string;
tags: string;
statusName: string;
isPublish: boolean;
needApprovalFromLevel: number;
files: FileType[];
uploadedById: number;
};
const ViewEditor = dynamic(
() => {
return import("@/components/editor/view-editor");
},
{ ssr: false },
{ ssr: false }
);
export default function FormTeksDetail() {
@ -235,7 +195,7 @@ export default function FormTeksDetail() {
const handleCheckboxChange = (id: number) => {
setSelectedPublishers((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id],
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]
);
};
@ -247,16 +207,6 @@ export default function FormTeksDetail() {
initState();
}, []);
useEffect(() => {
if (!detail?.publishedFor) return;
const publisherIds = detail.publishedFor
.split(",")
.map((id: any) => Number(id.trim()));
setSelectedPublishers(publisherIds);
}, [detail]);
const getCategories = async () => {
try {
const categoryRes = await listArticleCategories(1, 100);
@ -293,18 +243,6 @@ export default function FormTeksDetail() {
setFiles(details?.files || []);
setDetail(details);
// 🔥 Parse publish target seperti content image
const rawPublished =
details?.published_for || details?.publishedFor || "";
if (rawPublished) {
const publisherIds = rawPublished
.split(",")
.map((id: string) => Number(id.trim()));
setSelectedPublishers(publisherIds);
}
// ✅ Aman untuk fileType
setMain({
type: details?.fileType?.name || "Unknown",
@ -315,7 +253,7 @@ export default function FormTeksDetail() {
if (details?.publishedForObject) {
const publisherIds = details.publishedForObject.map(
(obj: any) => obj.id,
(obj: any) => obj.id
);
setSelectedPublishers(publisherIds);
}
@ -421,7 +359,7 @@ export default function FormTeksDetail() {
const setupPlacement = (
index: number,
placement: string,
checked: boolean,
checked: boolean
) => {
let temp = [...filePlacements];
if (checked) {
@ -462,7 +400,7 @@ export default function FormTeksDetail() {
type: string,
url: string,
names: string,
format: string,
format: string
) => {
console.log("Test 3 :", type, url, names, format);
setMain({
@ -486,8 +424,6 @@ export default function FormTeksDetail() {
});
};
const isPending = Number(detail?.statusId) === 1;
return (
<form>
{detail !== undefined ? (
@ -592,10 +528,10 @@ export default function FormTeksDetail() {
</Card>
<div className="w-full lg:w-4/12 m-2">
<Card className="pb-3">
<div className="px-3 py-3">
<Label>Creator</Label>
<Input value={detail.createdByName} disabled />
</div>
<div className="px-3 py-3">
<Label>Creator</Label>
<Input value={detail.createdByName} disabled />
</div>
{/* <div className="mt-3 px-3">
<Label>Pratinjau Gambar Utama</Label>
<Card className="mt-2">
@ -624,58 +560,6 @@ export default function FormTeksDetail() {
</div>
</div>
<div className="px-3 py-3">
<div className="flex flex-col gap-2 space-y-2">
<Label>Publish Target</Label>
{/* UMUM = 4 */}
<div className="flex gap-2 items-center">
<input
type="checkbox"
id="4"
value="4"
checked={selectedPublishers.includes(4)}
readOnly
className="h-4 w-4 border border-gray-300 rounded"
/>
<Label htmlFor="4">UMUM</Label>
</div>
{/* JOURNALIS = 5 */}
<div className="flex gap-2 items-center">
<input
type="checkbox"
id="5"
value="5"
checked={selectedPublishers.includes(5)}
readOnly
className="h-4 w-4 border border-gray-300 rounded"
/>
<Label htmlFor="5">JOURNALIS</Label>
</div>
</div>
{/* <div className="flex flex-col gap-2 space-y-2">
<Label>Publish Target</Label>
<div className="flex gap-2 items-center">
<Checkbox
id="4"
checked={selectedPublishers.includes(5)}
disabled
/>
<Label htmlFor="4">UMUM</Label>
</div>
<div className="flex gap-2 items-center">
<Checkbox
id="5"
checked={selectedPublishers.includes(6)}
disabled
/>
<Label htmlFor="5">JOURNALIS</Label>
</div>
</div> */}
</div>
{/* <div className="px-3 py-3">
<div className="flex flex-col gap-2 space-y-2">
<Label>Publish Target</Label>
<div className="flex gap-2 items-center">
@ -697,7 +581,7 @@ export default function FormTeksDetail() {
<Label htmlFor="6">JOURNALIS</Label>
</div>
</div>
</div> */}
</div>
<SuggestionModal
id={Number(id)}
numberOfSuggestion={detail?.numberOfSuggestion}
@ -763,7 +647,7 @@ export default function FormTeksDetail() {
id="terms"
value="all"
checked={filePlacements[index]?.includes(
"all",
"all"
)}
onCheckedChange={(e) =>
setupPlacement(index, "all", Boolean(e))
@ -780,7 +664,7 @@ export default function FormTeksDetail() {
<Checkbox
id="terms"
checked={filePlacements[index]?.includes(
"mabes",
"mabes"
)}
onCheckedChange={(e) =>
setupPlacement(index, "mabes", Boolean(e))
@ -797,7 +681,7 @@ export default function FormTeksDetail() {
<Checkbox
id="terms"
checked={filePlacements[index]?.includes(
"polda",
"polda"
)}
onCheckedChange={(e) =>
setupPlacement(index, "polda", Boolean(e))
@ -815,13 +699,13 @@ export default function FormTeksDetail() {
<Checkbox
id="terms"
checked={filePlacements[index]?.includes(
"international",
"international"
)}
onCheckedChange={(e) =>
setupPlacement(
index,
"international",
Boolean(e),
Boolean(e)
)
}
/>
@ -933,17 +817,10 @@ export default function FormTeksDetail() {
</DialogContent>
</Dialog>
</Card>
{/* {isPending &&
(Number(detail?.needApprovalFromLevel || 0) ===
Number(userLevelId) ||
(detail?.isInternationalMedia === true &&
detail?.isForwardFromNational === true)) ? (
Number(detail?.createdById || detail?.uploadedById) ==
Number(userId) ? (
{/* {Number(detail?.needApprovalFromLevel) == Number(userLevelId) ? (
Number(detail?.uploadedById) == Number(userId) ? (
""
) : ( */}
{isPending && (
<AccessGuard action="approve">
<div className="flex flex-col gap-2 p-3">
<Button
onClick={() => actionApproval("2")}
@ -952,7 +829,6 @@ export default function FormTeksDetail() {
>
<Icon icon="fa:check" className="mr-3" /> Accept
</Button>
<Button
onClick={() => actionApproval("3")}
className="bg-orange-400 hover:bg-orange-300"
@ -960,7 +836,6 @@ export default function FormTeksDetail() {
>
<Icon icon="fa:comment-o" className="mr-3" /> Revision
</Button>
<Button
onClick={() => actionApproval("4")}
color="destructive"
@ -970,9 +845,7 @@ export default function FormTeksDetail() {
Reject
</Button>
</div>
</AccessGuard>
)}
{/* )
{/* )
) : (
""
)} */}

View File

@ -73,7 +73,7 @@ const CustomEditor = dynamic(
() => {
return import("@/components/editor/custom-editor");
},
{ ssr: false },
{ ssr: false }
);
type Option = {
@ -114,7 +114,7 @@ export default function FormTeks() {
const [isGeneratedArticle, setIsGeneratedArticle] = useState(false);
const [articleBody, setArticleBody] = useState<string>("");
const [selectedArticleId, setSelectedArticleId] = useState<string | null>(
null,
null
);
const [isContentRewriteClicked, setIsContentRewriteClicked] = useState(false);
const [showRewriteEditor, setShowRewriteEditor] = useState(false);
@ -168,13 +168,13 @@ export default function FormTeks() {
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
].includes(file.type) && file.size <= 20 * 1024 * 1024,
].includes(file.type) && file.size <= 20 * 1024 * 1024
);
const filesWithPreview = filtered.map((file) =>
Object.assign(file, {
preview: URL.createObjectURL(file),
}),
})
);
setFiles(filesWithPreview);
@ -200,12 +200,12 @@ export default function FormTeks() {
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"text/plain",
].includes(file.type) && file.size <= 100 * 1024 * 1024,
].includes(file.type) && file.size <= 100 * 1024 * 1024
),
{
message:
"Hanya file .pdf, .doc, .docx, .txt, maksimal 100MB yang diperbolehkan.",
},
}
),
categoryId: z.string().min(1, { message: "Kategori wajib dipilih." }),
tags: z
@ -420,7 +420,7 @@ export default function FormTeks() {
const articleData = await waitForStatusUpdate();
const cleanArticleBody = articleData?.articleBody?.replace(
/<img[^>]*>/g,
"",
""
);
const articleImagesData = articleData?.imagesUrl?.split(",");
setArticleBody(cleanArticleBody || "");
@ -500,7 +500,7 @@ export default function FormTeks() {
if (scheduleId && scheduleType === "3") {
const findCategory = resCategory.find((o) =>
o.name.toLowerCase().includes("pers rilis"),
o.name.toLowerCase().includes("pers rilis")
);
if (findCategory) {
@ -533,7 +533,7 @@ export default function FormTeks() {
setPublishedFor(
options
.filter((opt: any) => opt.id !== "all")
.map((opt: any) => opt.id),
.map((opt: any) => opt.id)
);
}
} else {
@ -572,8 +572,8 @@ export default function FormTeks() {
const finalDescription = isSwitchOn
? data.description
: selectedFileType === "rewrite"
? data.rewriteDescription
: data.descriptionOri;
? data.rewriteDescription
: data.descriptionOri;
if (!finalDescription?.trim()) {
MySwal.fire("Error", "Deskripsi tidak boleh kosong.", "error");
@ -637,10 +637,10 @@ export default function FormTeks() {
if (id == undefined) {
const articleData: CreateArticleData = {
aiArticleId: 0,
aiArticleId: 0,
categoryIds: selectedCategory.toString(),
createdAt: formatDateForBackend(new Date()),
createdById: Number(userId),
createdAt: formatDateForBackend(new Date()),
createdById: Number(userId),
description: htmlToString(finalDescription),
htmlDescription: finalDescription,
isDraft: true,
@ -652,8 +652,7 @@ export default function FormTeks() {
.replace(/[^a-z0-9-]/g, ""),
tags: finalTags,
title: finalTitle,
typeId: 3,
publishedFor: data.publishedFor.join(","),
typeId: 3,
};
const response = await createArticle(articleData);
console.log("Article Data Submitted:", articleData);
@ -663,7 +662,7 @@ export default function FormTeks() {
MySwal.fire(
"Error",
response.message || "Failed to create article",
"error",
"error"
);
return false;
}
@ -687,7 +686,7 @@ export default function FormTeks() {
MySwal.fire(
"Error",
uploadResponse.message || "Failed to upload files",
"error",
"error"
);
return false;
}
@ -703,18 +702,18 @@ export default function FormTeks() {
try {
const thumbnailResponse = await uploadArticleThumbnail(
articleId,
thumbnailFormData,
thumbnailFormData
);
if (thumbnailResponse?.error) {
console.warn(
"Thumbnail upload failed:",
thumbnailResponse.message,
thumbnailResponse.message
);
} else {
console.log(
"Thumbnail uploaded successfully:",
thumbnailResponse,
thumbnailResponse
);
}
} catch (thumbnailError) {
@ -726,7 +725,7 @@ export default function FormTeks() {
MySwal.fire(
"Error",
"Failed to upload files. Please try again.",
"error",
"error"
);
return false;
}
@ -768,7 +767,7 @@ export default function FormTeks() {
idx: number,
id: string,
file: any,
duration: string,
duration: string
) {
console.log(idx, id, file, duration);
@ -805,7 +804,7 @@ export default function FormTeks() {
onChunkComplete: (
chunkSize: any,
bytesAccepted: any,
bytesTotal: any,
bytesTotal: any
) => {
const uploadPersen = Math.floor((bytesAccepted / bytesTotal) * 100);
progressInfo[idx].percentage = uploadPersen;
@ -1037,36 +1036,6 @@ export default function FormTeks() {
<div className="flex flex-row items-center gap-3 py-2">
<Label>Ai Assistance</Label>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={isSwitchOn}
onChange={(e) => setIsSwitchOn(e.target.checked)}
className="sr-only peer"
/>
<div
className="
w-11 h-6
bg-gray-300
rounded-full
peer
peer-checked:bg-blue-600
transition-colors
after:content-['']
after:absolute
after:top-[2px]
after:left-[2px]
after:bg-white
after:rounded-full
after:h-5
after:w-5
after:transition-transform
peer-checked:after:translate-x-5
"
/>
</label>
</div>
{/* <div className="flex items-center gap-3">
<Switch
defaultChecked={isSwitchOn}
color="primary"
@ -1075,7 +1044,7 @@ export default function FormTeks() {
setIsSwitchOn(checked)
}
/>
</div> */}
</div>
</div>
{isSwitchOn && (
<div>
@ -1548,42 +1517,19 @@ export default function FormTeks() {
? isAllChecked
: field.value.includes(option.id);
// const handleChange = () => {
// let updated: string[] = [];
// if (option.id === "all") {
// updated = isAllChecked
// ? []
// : options
// .filter((opt: any) => opt.id !== "all")
// .map((opt: any) => opt.id);
// } else {
// updated = isChecked
// ? field.value.filter((val) => val !== option.id)
// : [...field.value, option.id];
// if (isAllChecked && option.id !== "all") {
// updated = updated.filter((val) => val !== "all");
// }
// }
// field.onChange(updated);
// setPublishedFor(updated);
// };
const handleChange = (checked: boolean) => {
const handleChange = () => {
let updated: string[] = [];
if (option.id === "all") {
updated = checked
? options
updated = isAllChecked
? []
: options
.filter((opt: any) => opt.id !== "all")
.map((opt: any) => opt.id)
: [];
.map((opt: any) => opt.id);
} else {
updated = checked
? [...field.value, option.id]
: field.value.filter((val) => val !== option.id);
updated = isChecked
? field.value.filter((val) => val !== option.id)
: [...field.value, option.id];
if (isAllChecked && option.id !== "all") {
updated = updated.filter((val) => val !== "all");
@ -1599,19 +1545,12 @@ export default function FormTeks() {
key={option.id}
className="flex gap-2 items-center"
>
<input
type="checkbox"
id={option.id}
checked={isChecked}
onChange={(e) => handleChange(e.target.checked)}
className="h-4 w-4 border border-gray-300 rounded text-blue-600 focus:ring-blue-500"
/>
{/* <Checkbox
<Checkbox
id={option.id}
checked={isChecked}
onCheckedChange={handleChange}
className="border"
/> */}
/>
<Label htmlFor={option.id}>{option.label}</Label>
</div>
);

View File

@ -16,7 +16,6 @@ import * as z from "zod";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import { useParams, useRouter } from "next/navigation";
import { v4 as uuidv4 } from "uuid";
import {
Select,
SelectContent,
@ -71,12 +70,12 @@ const teksSchema = z.object({
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"text/plain",
].includes(file.type) && file.size <= 10 * 1024 * 1024,
].includes(file.type) && file.size <= 10 * 1024 * 1024
),
{
message:
"Hanya file .pdf, .doc, .docx, atau .txt dengan ukuran maksimal 10MB yang diperbolehkan.",
},
}
),
publishedFor: z
@ -89,12 +88,6 @@ type Category = {
title: string;
};
interface DetailFile {
id: number;
fileUrl: string;
fileName: string;
}
type Detail = {
id: string;
title: string;
@ -117,7 +110,6 @@ type Detail = {
interface FileWithPreview extends File {
preview: string;
id: string;
}
type Option = {
@ -129,7 +121,7 @@ const CustomEditor = dynamic(
() => {
return import("@/components/editor/custom-editor");
},
{ ssr: false },
{ ssr: false }
);
export default function FormTeksUpdate() {
@ -161,7 +153,6 @@ export default function FormTeksUpdate() {
[fileId: number]: string[];
}>({});
const [selectedTarget, setSelectedTarget] = useState("");
const [detailFiles, setDetailFiles] = useState<DetailFile[]>([]);
const [unitSelection, setUnitSelection] = useState({
allUnit: false,
mabes: false,
@ -170,26 +161,18 @@ export default function FormTeksUpdate() {
});
const [publishedFor, setPublishedFor] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const [existingFiles, setExistingFiles] = useState<DetailFile[]>([]);
let fileTypeId = "3";
const { getRootProps, getInputProps } = useDropzone({
onDrop: (acceptedFiles) => {
setFiles(
acceptedFiles.map((file) =>
Object.assign(file, {
id: uuidv4(),
preview: URL.createObjectURL(file),
}),
),
);
setFiles(acceptedFiles.map((file) => Object.assign(file)));
},
accept: {
"application/pdf": [],
"application/msword": [],
"application/msword": [], // .doc
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
[],
[], // .docx
},
});
@ -236,15 +219,9 @@ export default function FormTeksUpdate() {
setValue("creatorName", details.createdByName ?? "");
setTags(details.tags?.split(",").map((t: string) => t.trim()) || []);
setPublishedFor(details.publishedFor?.split(",") || []);
if (details?.publishedFor) {
const publishArr = details.publishedFor.split(",");
setPublishedFor(publishArr); // state lokal
setValue("publishedFor", publishArr); // ← WAJIB untuk react-hook-form
}
if (details?.files) {
setExistingFiles(details.files);
setFiles(details.files);
const initialOptions: { [key: number]: string[] } = {};
details.files.forEach((file: any) => {
if (file.placements) {
@ -296,11 +273,12 @@ export default function FormTeksUpdate() {
.filter(Boolean);
const allSelected = ["nasional", "wilayah", "internasional"].every((opt) =>
options.includes(opt),
options.includes(opt)
);
return allSelected ? ["all", ...options] : options;
};
const handleCheckboxChange = (id: string) => {
if (id === "all") {
// Select all options except "all"
@ -308,12 +286,12 @@ export default function FormTeksUpdate() {
.filter((opt) => opt.id !== "all")
.map((opt) => opt.id);
setPublishedFor(
publishedFor.length === allOptions.length ? [] : allOptions,
publishedFor.length === allOptions.length ? [] : allOptions
);
} else {
// Toggle individual option
setPublishedFor((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id],
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]
);
}
};
@ -338,32 +316,20 @@ export default function FormTeksUpdate() {
// isYoutube: false,
// isInternationalMedia: false,
// };
const payload = {
aiArticleId: detail.aiArticleId,
aiArticleId: detail?.aiArticleId ?? "",
categoryIds: selectedCategory,
createdById: detail?.createdById ?? "",
description: htmlToString(data.description),
htmlDescription: data.description,
isDraft: false,
isPublish: true,
slug: detail?.slug ?? data.title.toLowerCase().replace(/\s+/g, "-"),
statusId: detail?.statusId ?? 1,
tags: tags.join(","),
title: data.title,
typeId: detail.typeId,
slug: detail?.slug ?? data.title.toLowerCase().replace(/\s+/g, "-"),
publishedFor: data.publishedFor.join(","),
typeId: detail?.typeId ?? 3,
};
// const payload = {
// aiArticleId: detail?.aiArticleId ?? "",
// categoryIds: selectedCategory,
// createdById: detail?.createdById ?? "",
// description: htmlToString(data.description),
// htmlDescription: data.description,
// isDraft: false,
// isPublish: true,
// slug: detail?.slug ?? data.title.toLowerCase().replace(/\s+/g, "-"),
// statusId: detail?.statusId ?? 1,
// tags: tags.join(","),
// title: data.title,
// typeId: detail?.typeId ?? 3,
// };
const res = await updateArticle(Number(id), payload);
if (res?.error) {
@ -420,7 +386,7 @@ export default function FormTeksUpdate() {
idx: number,
id: string,
file: any,
duration: string,
duration: string
) {
console.log(idx, id, file, duration);
@ -458,7 +424,7 @@ export default function FormTeksUpdate() {
onChunkComplete: (
chunkSize: any,
bytesAccepted: any,
bytesTotal: any,
bytesTotal: any
) => {
const uploadPersen = Math.floor((bytesAccepted / bytesTotal) * 100);
progressInfo[idx].percentage = uploadPersen;
@ -602,7 +568,7 @@ export default function FormTeksUpdate() {
// If all individual options are selected, include "all" automatically
const isAllSelected = ["nasional", "wilayah", "internasional"].every(
(opt) => updatedSelections.includes(opt),
(opt) => updatedSelections.includes(opt)
);
return {
...prev,
@ -633,7 +599,7 @@ export default function FormTeksUpdate() {
const handleEditTag = (index: number, newValue: string) => {
setTags((prevTags) =>
prevTags.map((tag, i) => (i === index ? newValue : tag)),
prevTags.map((tag, i) => (i === index ? newValue : tag))
);
};
@ -702,142 +668,151 @@ export default function FormTeksUpdate() {
)}
</div>
<div className="py-3 space-y-2">
<Label>Select File</Label>
{/* <Input
id="fileInput"
type="file"
onChange={handleImageChange}
/> */}
<Fragment>
<div className="py-3 space-y-2">
<Label>Select File</Label>
<Fragment>
<div {...getRootProps({ className: "dropzone" })}>
<input {...getInputProps()} />
<div className="w-full text-center border-dashed border border-black rounded-md py-[52px] flex items-center flex-col">
<CloudUpload className="w-10 h-10" />
<h4 className="text-2xl font-medium mt-3">
Drag File
</h4>
</div>
<div {...getRootProps({ className: "dropzone" })}>
<input {...getInputProps()} />
<div className=" w-full text-center border-dashed border border-default-200 dark:border-default-300 rounded-md py-[52px] flex items-center flex-col">
<CloudUpload className="text-default-300 w-10 h-10" />
<h4 className=" text-2xl font-medium mb-1 mt-3 text-card-foreground/80">
{/* Drop files here or click to upload. */}
Drag File
</h4>
<div className=" text-xs text-muted-foreground">
Upload File Text Max
</div>
{/* 👇 TARUH DI SINI */}
{files.length > 0 && (
<div className="mt-4 space-y-2">
<Label className="text-lg font-semibold">
File Baru
</Label>
{files.map((file) => (
<div
key={file.name}
className="flex justify-between items-center border p-3 rounded-md"
>
<div>
<p className="font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">
{(file.size / 1024).toFixed(1)} KB
</p>
</div>
<Button
type="button"
size="icon"
variant="outline"
onClick={() =>
setFiles((prev) =>
prev.filter(
(item) => item.name !== file.name,
),
)
}
>
</Button>
</div>
))}
</div>
)}
</Fragment>
</div>
</div>
{/* {files.length > 0 && (
<div className="mt-4">
<Label className="text-md font-semibold">
{files.length ? (
<Fragment>
<div>{fileList}</div>
<div className=" flex justify-between gap-2">
<div className="flex flex-row items-center gap-3 py-3">
<Label>Watermark</Label>
<div className="flex items-center gap-3">
<Switch defaultChecked color="primary" id="c2" />
</div>
</div>
<Button
color="destructive"
onClick={handleRemoveAllFiles}
>
Remove All
</Button>
</div>
</Fragment>
) : null}
{files.length > 0 && (
<div className="mt-4 space-y-2">
<Label className="text-lg font-semibold">
{" "}
File Media
</Label>
<div className="grid gap-4">
{files.map((file: any, index: number) => (
{files.map((file: any) => (
<div
key={file.id}
className="flex items-center border p-2 rounded-md"
>
{file.preview ? (
<img
src={file.preview}
alt={file.name}
className="w-16 h-16 object-cover rounded-md mr-4"
/>
) : (
<Icon
icon="tabler:file-description"
className="w-16 h-16"
/>
)}
<div className="flex-grow">
<p className="font-medium">
{file.fileName || file.name}
</p>
<img
src={file.thumbnailFileUrl}
alt={file.fileName}
className="w-16 h-16 object-cover rounded-md mr-4"
/>
<div className="flex flex-wrap gap-3 items-center ">
<div className="flex-grow">
<p className="font-medium">{file.fileName}</p>
<a
href={file.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 text-sm"
>
View File
</a>
</div>
<div>
<Label className="flex items-center space-x-2">
<input
type="checkbox"
checked={selectedOptions[
file.id
]?.includes("all")}
onChange={() =>
handleCheckboxChangeImage(
file.id,
"all"
)
}
className="form-checkbox"
/>
<span>All</span>
</Label>
</div>
<div>
<Label className="flex items-center space-x-2">
<input
type="checkbox"
checked={selectedOptions[
file.id
]?.includes("nasional")}
onChange={() =>
handleCheckboxChangeImage(
file.id,
"nasional"
)
}
className="form-checkbox"
/>
<span>Nasional</span>
</Label>
</div>
<div>
<Label className="flex items-center space-x-2">
<input
type="checkbox"
checked={selectedOptions[
file.id
]?.includes("wilayah")}
onChange={() =>
handleCheckboxChangeImage(
file.id,
"wilayah"
)
}
className="form-checkbox"
/>
<span>Wilayah</span>
</Label>
</div>
<div>
<Label className="flex items-center space-x-2">
<input
type="checkbox"
checked={selectedOptions[
file.id
]?.includes("internasional")}
onChange={() =>
handleCheckboxChangeImage(
file.id,
"internasional"
)
}
className="form-checkbox"
/>
<span>Internasional</span>
</Label>
</div>
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
setFiles((prev) =>
prev.filter((f) => f.id !== file.id),
)
}
>
</Button>
</div>
))}
</div>
</div>
)} */}
{/* Existing Files */}
{existingFiles.length > 0 && (
<div className="mt-4 space-y-2">
<Label className="text-lg font-semibold">
File Sebelumnya
</Label>
{existingFiles.map((file) => (
<div
key={file.id}
className="flex justify-between items-center border p-3 rounded-md"
>
<div className="flex items-center gap-3">
<Icon
icon="tabler:file-description"
className="w-10 h-10"
/>
<div>
<p className="font-medium">{file.fileName}</p>
<a
href={file.fileUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 text-sm"
>
Lihat File
</a>
</div>
</div>
</div>
))}
</div>
)}
</Fragment>
</div>
@ -865,18 +840,11 @@ export default function FormTeksUpdate() {
{/* <div className="mt-3 px-3">
<Label>Pratinjau Gambar Utama</Label>
<Card className="mt-2">
{files.preview ? (
<img
src={files.preview}
alt={files.name}
className="w-16 h-16 object-cover rounded-md mr-4"
/>
) : (
<Icon
icon="tabler:file-description"
className="w-16 h-16"
/>
)}
<img
src={detail.thumbnailLink}
alt="Thumbnail Gambar Utama"
className="w-full h-auto rounded"
/>
</Card>
</div> */}
<div className="px-3 py-3">
@ -918,76 +886,6 @@ export default function FormTeksUpdate() {
<div className="mt-4 space-y-2">
<Label>Publish Target</Label>
<Controller
control={control}
name="publishedFor"
render={({ field }) => {
const currentValue = field.value || [];
const isAllChecked =
currentValue.length ===
options.filter((opt) => opt.id !== "all").length;
return (
<div className="flex flex-col gap-3">
{options.map((option) => {
const isChecked =
option.id === "all"
? isAllChecked
: currentValue.includes(option.id);
const handleChange = (checked: boolean) => {
let updated: string[] = [];
if (option.id === "all") {
updated = checked
? options
.filter((opt) => opt.id !== "all")
.map((opt) => opt.id)
: [];
} else {
updated = checked
? [...currentValue, option.id]
: currentValue.filter(
(val) => val !== option.id,
);
}
field.onChange(updated);
setPublishedFor(updated);
};
return (
<div
key={option.id}
className="flex items-center gap-2"
>
<input
type="checkbox"
id={option.id}
checked={isChecked}
onChange={(e) =>
handleChange(e.target.checked)
}
/>
<Label htmlFor={option.id}>
{option.label}
</Label>
</div>
);
})}
{errors.publishedFor && (
<p className="text-red-500 text-sm">
{errors.publishedFor.message}
</p>
)}
</div>
);
}}
/>
{/* <Controller
control={control}
name="publishedFor"
render={({ field }) => (
@ -1016,13 +914,13 @@ export default function FormTeksUpdate() {
} else {
updated = isChecked
? field.value.filter(
(val) => val !== option.id,
(val) => val !== option.id
)
: [...field.value, option.id];
if (isAllChecked && option.id !== "all") {
updated = updated.filter(
(val) => val !== "all",
(val) => val !== "all"
);
}
}
@ -1057,7 +955,7 @@ export default function FormTeksUpdate() {
</div>
</div>
)}
/> */}
/>
</div>
</div>
<div className="px-3 py-3 flex flex-row items-center text-blue-500 gap-2 text-sm">

View File

@ -60,7 +60,6 @@ import {
getDataApprovalByMediaUpload,
} from "@/service/curated-content/curated-content";
import { UnitMapping } from "../unit-mapping";
import { AccessGuard } from "@/components/access-guard";
const imageSchema = z.object({
title: z.string().min(1, { message: "Judul diperlukan" }),
@ -127,7 +126,6 @@ type Detail = {
createdAt: string;
updatedAt: string;
files: FileType[] | null;
published_for?: string;
categories: {
id: number;
title: string;
@ -163,7 +161,7 @@ const ViewEditor = dynamic(
() => {
return import("@/components/editor/view-editor");
},
{ ssr: false },
{ ssr: false }
);
export default function FormImageDetail() {
@ -173,8 +171,10 @@ export default function FormImageDetail() {
const userLevelId = getCookiesDecrypt("ulie");
const userLevelName = Cookies.get("state");
const roleId = getCookiesDecrypt("urie");
console.log("LALALALA", userLevelName);
const [modalOpen, setModalOpen] = useState(false);
const { id } = useParams() as { id: string };
console.log("IDIDIDIDI", id);
const editor = useRef(null);
type ImageSchema = z.infer<typeof imageSchema>;
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
@ -233,7 +233,7 @@ export default function FormImageDetail() {
const handleCheckboxChange = (id: number) => {
setSelectedPublishers((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id],
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]
);
};
@ -255,7 +255,7 @@ export default function FormImageDetail() {
if (scheduleId && scheduleType === "3") {
const findCategory = resCategory?.find((o) =>
o.name.toLowerCase().includes("pers rilis"),
o.name.toLowerCase().includes("pers rilis")
);
if (findCategory) {
@ -323,22 +323,12 @@ export default function FormImageDetail() {
try {
const response = await getArticleDetail(Number(id));
const details = response?.data?.data;
console.log("detail", details);
console.log("DETAIL RESPONSE:", details);
// ===== PARSE published_for =====
const rawPublished =
details?.published_for || details?.publishedFor || "";
if (rawPublished) {
const publisherIds = rawPublished
.split(",")
.map((id: string) => Number(id.trim()));
setSelectedPublishers(publisherIds);
}
// Map the new API response to the expected format
const mappedDetail: Detail = {
...details,
// Map legacy fields for backward compatibility
category:
details.categories && details.categories.length > 0
? {
@ -349,34 +339,28 @@ export default function FormImageDetail() {
creatorName: details.createdByName,
thumbnailLink: details.thumbnailUrl,
statusName: getStatusName(details.statusId),
needApprovalFromLevel: 0,
needApprovalFromLevel: 0, // This might need to be updated based on your business logic
uploadedById: details.createdById,
files: details.files || [],
};
// Map files from new API structure to expected format
const mappedFiles = (mappedDetail.files || []).map((file: any) => ({
id: file.id,
url: file.fileUrl || file.url,
thumbnailFileUrl:
file.fileThumbnail || file.thumbnailFileUrl || file.fileUrl,
fileName: file.fileName || file.fileName,
// Keep original API fields for reference
...file,
}));
setFiles(mappedFiles);
setDetail(mappedDetail);
if (details?.published_for) {
const publisherIds = details.published_for
.split(",")
.map((id: string) => Number(id.trim()));
setSelectedPublishers(publisherIds);
}
if (mappedFiles && mappedFiles.length > 0) {
setMain({
type: "image",
type: "image", // Default type for articles
url: mappedFiles[0]?.url || mappedDetail.thumbnailUrl,
names: mappedFiles[0]?.fileName || "image",
format: getFileExtension(mappedFiles[0]?.fileName || "jpg"),
@ -384,22 +368,19 @@ export default function FormImageDetail() {
setupPlacementCheck(mappedFiles.length);
}
// Set the selected target to the category ID from details
setSelectedTarget(String(mappedDetail.categoryId));
const fileUrls = (mappedFiles || []).map(
(file) => file.thumbnailFileUrl || file.url || "default-image.jpg",
const fileUrls = mappedFiles.map(
(file: any) =>
file.thumbnailFileUrl ||
file.url ||
mappedDetail.thumbnailUrl ||
"default-image.jpg"
);
setDetailThumb(fileUrls);
// if (details?.publishedForObject?.length > 0) {
// const publisherIds = details.publishedForObject
// .map((obj: any) => Number(obj.id))
// .filter((id: number) => id === 4 || id === 5);
// setSelectedPublishers(publisherIds);
// }
// Note: You might need to update this API call as well
const approvals = await getDataApprovalByMediaUpload(mappedDetail.id);
setApproval(approvals?.data?.data);
} catch (error) {
@ -410,6 +391,7 @@ export default function FormImageDetail() {
initState();
}, [refresh, setValue]);
// Helper function to get status name from status ID
const getStatusName = (statusId: number): string => {
const statusMap: { [key: number]: string } = {
1: "Menunggu Review",
@ -500,7 +482,7 @@ export default function FormImageDetail() {
const setupPlacement = (
index: number,
placement: string,
checked: boolean,
checked: boolean
) => {
let temp = [...filePlacements];
if (checked) {
@ -541,7 +523,7 @@ export default function FormImageDetail() {
type: string,
url: string,
names: string,
format: string,
format: string
) => {
console.log("Test 3 :", type, url, names, format);
setMain({
@ -588,15 +570,6 @@ export default function FormImageDetail() {
console.log("portrai", portraitMap);
}, [portraitMap]);
const isPending = Number(detail?.statusId) === 1;
const isApproved = Number(detail?.statusId) === 2;
const isRejected = Number(detail?.statusId) === 4;
const isCreator =
Number(detail?.createdById || detail?.uploadedById) === Number(userId);
const hasApproval = approval != null;
return (
<form>
{detail !== undefined ? (
@ -646,14 +619,14 @@ export default function FormImageDetail() {
!categories?.find(
(cat) =>
String(cat.id) ===
String(detail.categoryId || detail?.category?.id),
String(detail.categoryId || detail?.category?.id)
) && (
<SelectItem
key={String(
detail.categoryId || detail?.category?.id,
detail.categoryId || detail?.category?.id
)}
value={String(
detail.categoryId || detail?.category?.id,
detail.categoryId || detail?.category?.id
)}
>
{detail.categoryName || detail?.category?.name}
@ -696,7 +669,7 @@ export default function FormImageDetail() {
navigation={false}
className="h-[480px] object-cover w-full"
>
{/* {detailThumb?.map((data: any, index: number) => (
{detailThumb?.map((data: any, index: number) => (
<SwiperSlide key={index}>
<img
className="h-[480px] max-w-[600px] rounded-md object-cover mx-auto border-2"
@ -704,15 +677,6 @@ export default function FormImageDetail() {
alt={`Image ${index + 1}`}
/>
</SwiperSlide>
))} */}
{detailThumb?.map((url: string, index: number) => (
<SwiperSlide key={index}>
<img
src={url}
alt={`Image ${index + 1}`}
className="h-[480px] max-w-[600px] rounded-md object-cover mx-auto border-2"
/>
</SwiperSlide>
))}
</Swiper>
<div className="mt-2 mx-auto min-w-fit max-w-[600px]">
@ -796,54 +760,26 @@ export default function FormImageDetail() {
<div className="px-3 py-3">
<div className="flex flex-col gap-2 space-y-2">
<Label>Publish Target</Label>
{/* UMUM = 4 */}
<div className="flex gap-2 items-center">
<input
type="checkbox"
id="4"
value="4"
checked={selectedPublishers.includes(4)}
readOnly
className="h-4 w-4 border border-gray-300 rounded"
/>
<Label htmlFor="4">UMUM</Label>
</div>
{/* JOURNALIS = 5 */}
<div className="flex gap-2 items-center">
<input
type="checkbox"
id="5"
value="5"
checked={selectedPublishers.includes(5)}
readOnly
className="h-4 w-4 border border-gray-300 rounded"
/>
<Label htmlFor="5">JOURNALIS</Label>
</div>
</div>
{/* <div className="flex flex-col gap-2 space-y-2">
<Label>Publish Target</Label>
<div className="flex gap-2 items-center">
<Checkbox
id="4"
checked={selectedPublishers.includes(5)}
disabled
/>
<Label htmlFor="4">UMUM</Label>
</div>
<div className="flex gap-2 items-center">
<Checkbox
id="5"
checked={selectedPublishers.includes(5)}
onChange={() => handleCheckboxChange(5)}
className="border"
/>
<Label htmlFor="5">UMUM</Label>
</div>
<div className="flex gap-2 items-center">
<Checkbox
id="6"
checked={selectedPublishers.includes(6)}
disabled
onChange={() => handleCheckboxChange(6)}
className="border"
/>
<Label htmlFor="5">JOURNALIS</Label>
<Label htmlFor="6">JOURNALIS</Label>
</div>
</div> */}
</div>
</div>
<SuggestionModal
@ -911,7 +847,7 @@ export default function FormImageDetail() {
)} */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-h-[600px] w-full">
<DialogContent className="max-h-[600px]">
<DialogHeader>
<DialogTitle>Leave Comment</DialogTitle>
</DialogHeader>
@ -923,7 +859,7 @@ export default function FormImageDetail() {
className="flex flex-row gap-5 items-center w-full"
>
<div className="w-[200px] h-[100px] flex justify-center items-center">
{/* <img
<img
key={index}
alt={file.fileAlt || `files-${index + 1}`}
src={file.url}
@ -931,19 +867,6 @@ export default function FormImageDetail() {
className={`h-[100px] object-cover ${
portraitMap[index] ? "w-auto" : "!w-[200px]"
}`}
/> */}
<img
alt={file.fileAlt || `files-${index + 1}`}
src={
file.fileUrl ||
file.url ||
file.fileThumbnail ||
file.thumbnailFileUrl
}
onLoad={(e) => handleImageLoad(e, index)}
className={`h-[100px] object-contain ${
portraitMap[index] ? "w-auto" : "w-[200px]"
}`}
/>
</div>
<div className="flex flex-col gap-2 w-full">
@ -964,7 +887,7 @@ export default function FormImageDetail() {
id="terms"
value="all"
checked={filePlacements[index]?.includes(
"all",
"all"
)}
onCheckedChange={(e) =>
setupPlacement(index, "all", Boolean(e))
@ -981,13 +904,13 @@ export default function FormImageDetail() {
<Checkbox
id="terms"
checked={filePlacements[index]?.includes(
"mabes",
"mabes"
)}
onCheckedChange={(e) =>
setupPlacement(
index,
"mabes",
Boolean(e),
Boolean(e)
)
}
/>
@ -1002,13 +925,13 @@ export default function FormImageDetail() {
<Checkbox
id="terms"
checked={filePlacements[index]?.includes(
"polda",
"polda"
)}
onCheckedChange={(e) =>
setupPlacement(
index,
"polda",
Boolean(e),
Boolean(e)
)
}
/>
@ -1033,13 +956,13 @@ export default function FormImageDetail() {
<Checkbox
id="terms"
checked={filePlacements[index]?.includes(
"international",
"international"
)}
onCheckedChange={(e) =>
setupPlacement(
index,
"international",
Boolean(e),
Boolean(e)
)
}
/>
@ -1160,53 +1083,15 @@ export default function FormImageDetail() {
</DialogContent>
</Dialog>
</Card>
{/* {(Number(detail.needApprovalFromLevel || 0) ==
Number(userLevelId) ||
(detail.isPublish === false && detail.statusId == 1)) &&
Number(detail.uploadedById || detail.createdById) !=
Number(userId) ? (
<div className="flex flex-col gap-2 p-3">
<Button
onClick={() => actionApproval("2")}
color="primary"
type="button"
>
<Icon icon="fa:check" className="mr-3" /> Accept
</Button>
<Button
onClick={() => actionApproval("3")}
className="bg-orange-400 hover:bg-orange-300"
type="button"
>
<Icon icon="fa:comment-o" className="mr-3" /> Revision
</Button>
<Button
onClick={() => actionApproval("4")}
color="destructive"
type="button"
>
<Icon icon="fa:times" className="mr-3" /> Reject
</Button>
</div>
) : null} */}
{/* {Number(detail?.needApprovalFromLevel || 0) ==
{Number(detail?.needApprovalFromLevel || 0) ==
Number(userLevelId) ||
(detail?.isInternationalMedia == true &&
detail?.isForwardFromNational == true &&
Number(detail?.statusId) == 1) ? ( */}
{/* {isPending &&
(Number(detail?.needApprovalFromLevel || 0) ===
Number(userLevelId) ||
(detail?.isInternationalMedia === true &&
detail?.isForwardFromNational === true)) ? (
Number(detail?.statusId) == 1) ? (
Number(detail?.createdById || detail?.uploadedById) ==
Number(userId) ? (
""
) : ( */}
{isPending && (
<AccessGuard action="approve">
) : (
<div className="flex flex-col gap-2 p-3">
<Button
onClick={() => actionApproval("2")}
@ -1232,12 +1117,10 @@ export default function FormImageDetail() {
Reject
</Button>
</div>
</AccessGuard>
)}
{/* )
)
) : (
""
)} */}
)}
</div>
</div>
) : (

View File

@ -80,7 +80,7 @@ const CustomEditor = dynamic(
() => {
return import("@/components/editor/custom-editor");
},
{ ssr: false },
{ ssr: false }
);
export default function FormImage() {
@ -113,7 +113,7 @@ export default function FormImage() {
const [isGeneratedArticle, setIsGeneratedArticle] = useState(false);
const [articleBody, setArticleBody] = useState<string>("");
const [selectedArticleId, setSelectedArticleId] = useState<string | null>(
null,
null
);
const [selectedMainKeyword, setSelectedMainKeyword] = useState("");
const [selectedWritingStyle, setSelectedWritingStyle] =
@ -169,17 +169,17 @@ export default function FormImage() {
.filter(
(file) =>
["image/jpeg", "image/png", "image/jpg"].includes(file.type) &&
file.size <= MAX_FILE_SIZE,
file.size <= MAX_FILE_SIZE
)
.map((file) =>
Object.assign(file, {
preview: URL.createObjectURL(file),
}),
})
);
if (validFiles.length === 0) {
toast.error(
"File tidak valid. Hanya .jpg, .jpeg, .png maksimal 100MB yang diperbolehkan.",
"File tidak valid. Hanya .jpg, .jpeg, .png maksimal 100MB yang diperbolehkan."
);
return;
}
@ -209,12 +209,12 @@ export default function FormImage() {
files.every(
(file: File) =>
["image/jpeg", "image/png", "image/jpg"].includes(file.type) &&
file.size <= 100 * 1024 * 1024,
file.size <= 100 * 1024 * 1024
),
{
message:
"Hanya file .jpg, .jpeg, .png, maksimal 100MB yang diperbolehkan.",
},
}
),
categoryId: z.string().min(1, { message: "Kategori wajib dipilih." }),
tags: z
@ -261,8 +261,10 @@ export default function FormImage() {
pointOfView: "None",
clientId: "",
};
console.log("Sending request for title with data:", titleData);
const titleRes = await getGenerateTitle(titleData);
setTitle(titleRes?.data?.data || "");
console.log("Generated title:", titleRes?.data?.data);
const keywordsData = {
keyword: selectedMainKeyword,
@ -273,9 +275,12 @@ export default function FormImage() {
pointOfView: "None",
clientId: "",
};
console.log("Sending request for keywords with data:", keywordsData);
const keywordsRes = await getGenerateKeywords(keywordsData);
setSelectedSEO(keywordsRes?.data?.data || []);
console.log("Generated keywords:", keywordsRes?.data?.data);
} catch (error) {
console.error("Error during generation process:", error);
} finally {
setIsLoading(false);
}
@ -305,7 +310,9 @@ export default function FormImage() {
console.log("Sending request for title with data:", titleData);
const titleRes = await getGenerateTitle(titleData);
setTitle(titleRes?.data?.data || "");
console.log("Generated title:", titleRes?.data?.data);
} catch (error) {
console.error("Error generating title:", error);
} finally {
setIsLoading(false);
}
@ -315,6 +322,7 @@ export default function FormImage() {
title: "WARNING",
text: "Please provide a valid title.",
});
console.error("Please provide a valid main keyword.");
}
};
@ -331,8 +339,10 @@ export default function FormImage() {
pointOfView: "None",
clientId: "",
};
console.log("Sending request for keywords with data:", keywordsData);
const keywordsRes = await getGenerateKeywords(keywordsData);
setSelectedSEO(keywordsRes?.data?.data || []);
console.log("Generated keywords:", keywordsRes?.data?.data);
} catch (error) {
console.error("Error generating keywords:", error);
} finally {
@ -375,12 +385,7 @@ export default function FormImage() {
return false;
}
// const newArticleId = res?.data?.data?.id;
const newArticleId =
res?.data?.data?.id ||
res?.data?.data?.articleId ||
res?.data?.data?.uuid;
const newArticleId = res?.data?.data?.id;
setIsGeneratedArticle(true);
setArticleIds((prevIds: string[]) => {
@ -420,7 +425,7 @@ export default function FormImage() {
const articleData = await waitForStatusUpdate();
const cleanArticleBody = articleData?.articleBody?.replace(
/<img[^>]*>/g,
"",
""
);
const articleImagesData = articleData?.imagesUrl?.split(",");
setArticleBody(cleanArticleBody || "");
@ -493,7 +498,7 @@ export default function FormImage() {
if (scheduleId && scheduleType === "3") {
const findCategory = resCategory.find((o) =>
o.name.toLowerCase().includes("pers rilis"),
o.name.toLowerCase().includes("pers rilis")
);
if (findCategory) {
@ -524,7 +529,7 @@ export default function FormImage() {
setPublishedFor(
options
.filter((opt: any) => opt.id !== "all")
.map((opt: any) => opt.id),
.map((opt: any) => opt.id)
);
}
} else {
@ -546,7 +551,7 @@ export default function FormImage() {
}
}, [articleBody, setValue]);
const userId = Cookies.get("userId");
const userId = Cookies.get("userId"); // atau dari auth context / localStorage
const save = async (data: ImageSchema) => {
loading();
@ -561,8 +566,8 @@ export default function FormImage() {
const finalDescription = isSwitchOn
? data.description
: selectedFileType === "rewrite"
? data.rewriteDescription
: data.descriptionOri;
? data.rewriteDescription
: data.descriptionOri;
if (!finalDescription?.trim()) {
MySwal.fire("Error", "Deskripsi tidak boleh kosong.", "error");
@ -588,10 +593,10 @@ export default function FormImage() {
// ✅ Sesuaikan dengan struktur Swagger
const articleData: CreateArticleData = {
aiArticleId: 0,
aiArticleId: 0, // default 0
categoryIds: selectedCategory.toString(),
createdAt: formatDateForBackend(new Date()),
createdById: Number(userId),
createdAt: formatDateForBackend(new Date()), // ✅ format sesuai backend
createdById: Number(userId), // isi dengan userId valid
description: htmlToString(finalDescription),
htmlDescription: finalDescription,
isDraft: true,
@ -603,31 +608,9 @@ export default function FormImage() {
.replace(/[^a-z0-9-]/g, ""),
tags: finalTags,
title: finalTitle,
typeId: 1,
// 🔥 TAMBAHKAN INI
publishedFor: data.publishedFor.join(","),
typeId: 1, // Image content type
};
// const articleData: CreateArticleData = {
// aiArticleId: 0, // default 0
// categoryIds: selectedCategory.toString(),
// createdAt: formatDateForBackend(new Date()), // ✅ format sesuai backend
// createdById: Number(userId), // isi dengan userId valid
// description: htmlToString(finalDescription),
// htmlDescription: finalDescription,
// isDraft: true,
// isPublish: false,
// oldId: 0,
// slug: finalTitle
// .toLowerCase()
// .replace(/\s+/g, "-")
// .replace(/[^a-z0-9-]/g, ""),
// tags: finalTags,
// title: finalTitle,
// typeId: 1, // Image content type
// };
let id = Cookies.get("idCreate");
if (id == undefined) {
@ -639,7 +622,7 @@ export default function FormImage() {
MySwal.fire(
"Error",
response.message || "Failed to create article",
"error",
"error"
);
return false;
}
@ -658,7 +641,7 @@ export default function FormImage() {
MySwal.fire(
"Error",
uploadResponse.message || "Failed to upload files",
"error",
"error"
);
return false;
}
@ -674,7 +657,7 @@ export default function FormImage() {
MySwal.fire(
"Error",
"Failed to upload files. Please try again.",
"error",
"error"
);
return false;
}
@ -716,7 +699,7 @@ export default function FormImage() {
idx: number,
id: string,
file: any,
duration: string,
duration: string
) {
console.log(idx, id, file, duration);
@ -752,7 +735,7 @@ export default function FormImage() {
onChunkComplete: (
chunkSize: any,
bytesAccepted: any,
bytesTotal: any,
bytesTotal: any
) => {
const uploadPersen = Math.floor((bytesAccepted / bytesTotal) * 100);
progressInfo[idx].percentage = uploadPersen;
@ -1007,43 +990,14 @@ export default function FormImage() {
<div className="flex flex-row items-center gap-3 py-3 ">
<Label>Ai Assistance</Label>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={isSwitchOn}
onChange={(e) => setIsSwitchOn(e.target.checked)}
className="sr-only peer"
/>
<div
className="
w-11 h-6
bg-gray-300
rounded-full
peer
peer-checked:bg-blue-600
transition-colors
after:content-['']
after:absolute
after:top-[2px]
after:left-[2px]
after:bg-white
after:rounded-full
after:h-5
after:w-5
after:transition-transform
peer-checked:after:translate-x-5
"
/>
</label>
{/* <Switch
<Switch
defaultChecked={isSwitchOn}
color="primary"
id="c2"
onCheckedChange={(checked: boolean) =>
setIsSwitchOn(checked)
}
/> */}
/>
</div>
</div>
{isSwitchOn && (
@ -1302,13 +1256,14 @@ export default function FormImage() {
<p className="text-sm font-semibold">Content Rewrite</p>
<div className="my-2">
<button
<Button
size="sm"
type="button"
onClick={handleRewriteClick}
className="bg-blue-500 text-white py-2 px-3 rounded hover:bg-black"
className="bg-blue-500 text-white py-2 px-4 rounded"
>
Content Rewrite
</button>
</Button>
</div>
{showRewriteEditor && (
@ -1538,19 +1493,19 @@ export default function FormImage() {
? isAllChecked
: field.value.includes(option.id);
const handleChange = (checked: boolean) => {
const handleChange = () => {
let updated: string[] = [];
if (option.id === "all") {
updated = checked
? options
updated = isAllChecked
? []
: options
.filter((opt: any) => opt.id !== "all")
.map((opt: any) => opt.id)
: [];
.map((opt: any) => opt.id);
} else {
updated = checked
? [...field.value, option.id]
: field.value.filter((val) => val !== option.id);
updated = isChecked
? field.value.filter((val) => val !== option.id)
: [...field.value, option.id];
if (isAllChecked && option.id !== "all") {
updated = updated.filter((val) => val !== "all");
@ -1561,48 +1516,17 @@ export default function FormImage() {
setPublishedFor(updated);
};
// const handleChange = () => {
// let updated: string[] = [];
// if (option.id === "all") {
// updated = isAllChecked
// ? []
// : options
// .filter((opt: any) => opt.id !== "all")
// .map((opt: any) => opt.id);
// } else {
// updated = isChecked
// ? field.value.filter((val) => val !== option.id)
// : [...field.value, option.id];
// if (isAllChecked && option.id !== "all") {
// updated = updated.filter((val) => val !== "all");
// }
// }
// field.onChange(updated);
// setPublishedFor(updated);
// };
return (
<div
key={option.id}
className="flex gap-2 items-center"
>
<input
type="checkbox"
id={option.id}
checked={isChecked}
onChange={(e) => handleChange(e.target.checked)}
className="h-4 w-4 border border-gray-300 rounded text-blue-600 focus:ring-blue-500"
/>
{/* <Checkbox
<Checkbox
id={option.id}
checked={isChecked}
onCheckedChange={handleChange}
className="border"
/> */}
/>
<Label htmlFor={option.id}>{option.label}</Label>
</div>
);

View File

@ -59,7 +59,7 @@ import { useTranslations } from "next-intl";
const CustomEditor = dynamic(
() => import("@/components/editor/custom-editor"),
{ ssr: false },
{ ssr: false }
);
const imageSchema = z.object({
@ -99,7 +99,7 @@ export default function FormImageUpdate() {
setFiles((prev) => [
...prev,
...acceptedFiles.map((f) =>
Object.assign(f, { id: uuidv4(), preview: URL.createObjectURL(f) }),
Object.assign(f, { id: uuidv4(), preview: URL.createObjectURL(f) })
),
]),
accept: { "image/*": [] },
@ -171,13 +171,12 @@ export default function FormImageUpdate() {
const allOptions = options
.filter((opt) => opt.id !== "all")
.map((opt) => opt.id);
setPublishedFor(
publishedFor.length === allOptions.length ? [] : allOptions,
publishedFor.length === allOptions.length ? [] : allOptions
);
} else {
setPublishedFor((prev) =>
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id],
prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]
);
}
};
@ -234,23 +233,6 @@ export default function FormImageUpdate() {
// router.push("/admin/content/image");
// });
// };
const formatDateTime = (date: Date) => {
const pad = (n: number) => n.toString().padStart(2, "0");
return (
date.getFullYear() +
"-" +
pad(date.getMonth() + 1) +
"-" +
pad(date.getDate()) +
" " +
pad(date.getHours()) +
":" +
pad(date.getMinutes()) +
":" +
pad(date.getSeconds())
);
};
// 🔹 ganti fungsi save di FormImageUpdate.tsx
const save = async (data: ImageSchema) => {
@ -266,10 +248,7 @@ export default function FormImageUpdate() {
const payload = {
aiArticleId: detail?.aiArticleId ?? null,
categoryIds: selectedTarget ? String(selectedTarget) : "",
// createdAt: detail?.createdAt ?? new Date().toISOString(),
createdAt: detail?.createdAt
? detail.createdAt.replace("T", " ").split("+")[0]
: formatDateTime(new Date()),
createdAt: detail?.createdAt ?? new Date().toISOString(),
createdById: detail?.createdById ?? null,
description: htmlToString(descFinal),
htmlDescription: descFinal,
@ -282,9 +261,9 @@ export default function FormImageUpdate() {
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)+/g, ""),
statusId: detail?.statusId ?? 1,
tags: tags.join(", "),
tags: tags,
title: data.title,
typeId: 1,
typeId: 1, // 1 = image (sesuai struktur kamu)
};
console.log("📤 Payload Update Article:", payload);
@ -400,14 +379,14 @@ export default function FormImageUpdate() {
!categories?.find(
(cat) =>
String(cat.id) ===
String(detail.categoryId || detail?.category?.id),
String(detail.categoryId || detail?.category?.id)
) && (
<SelectItem
key={String(
detail.categoryId || detail?.category?.id,
detail.categoryId || detail?.category?.id
)}
value={String(
detail.categoryId || detail?.category?.id,
detail.categoryId || detail?.category?.id
)}
>
{detail.categoryName || detail?.category?.name}
@ -468,7 +447,7 @@ export default function FormImageUpdate() {
className="flex justify-between border p-3 rounded-md"
>
<div className="flex gap-3 items-center">
{/* <Image
<Image
src={
file.thumbnailFileUrl ||
file.preview ||
@ -478,25 +457,11 @@ export default function FormImageUpdate() {
width={64}
height={64}
className="rounded border"
/> */}
<Image
src={
file.preview || // file baru (dropzone)
file.thumbnailUrl || // dari backend jika ada
file.fileUrl || // fallback utama dari backend
"/placeholder.png"
}
alt={file.fileName || file.name || "file"}
width={64}
height={64}
className="rounded border object-cover"
unoptimized
/>
<div>
<p className="font-medium">{file.fileName}</p>
<a
href={file.fileUrl || file.url}
href={file.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 text-sm"
@ -589,36 +554,6 @@ export default function FormImageUpdate() {
</div>
<div>
<Label>Publish Target</Label>
<div className="flex flex-col gap-2 mt-2">
{options.map((opt) => {
const isAllSelected =
publishedFor.length ===
options.filter((o) => o.id !== "all").length;
const isChecked =
opt.id === "all"
? isAllSelected
: publishedFor.includes(opt.id);
return (
<div key={opt.id} className="flex items-center gap-2">
<input
type="checkbox"
id={opt.id}
value={opt.id}
checked={isChecked}
onChange={() => handleCheckboxChange(opt.id)}
className="w-4 h-4 accent-black"
/>
<Label htmlFor={opt.id}>{opt.name}</Label>
</div>
);
})}
</div>
</div>
{/* <div>
<Label>Publish Target</Label>
<div className="flex flex-col gap-2 mt-2">
{options.map((opt) => (
@ -637,19 +572,12 @@ export default function FormImageUpdate() {
</div>
))}
</div>
</div> */}
</div>
</Card>
<div className="flex justify-end gap-3 mt-4">
<Button type="submit">Simpan</Button>
<Button
variant="outline"
type="submit"
className="hover:bg-gray-300"
>
Simpan
</Button>
<Button
className="hover:bg-gray-300"
type="button"
variant="outline"
onClick={() => router.back()}

View File

@ -56,7 +56,6 @@ export default function SignUp() {
const [whatsapp, setWhatsapp] = useState("");
const [namaTenant, setNamaTenant] = useState("");
const [tenantPassword, setTenantPassword] = useState("");
const [tenantUsername, setTenantUsername] = useState("");
const [confirmTenantPassword, setConfirmTenantPassword] = useState("");
const [firstNameKontributor, setFirstNameKontributor] = useState("");
const [lastNameKontributor, setLastNameKontributor] = useState("");
@ -69,7 +68,6 @@ export default function SignUp() {
const [isLoading, setIsLoading] = useState(false);
const [formErrors, setFormErrors] = useState<any>({});
const [tenantList, setTenantList] = useState<Tenant[]>([]);
const [usernameKontributor, setUsernameKontributor] = useState("");
useEffect(() => {
getTenant();
@ -96,7 +94,7 @@ export default function SignUp() {
// Kontributor (sementara ikut umum)
if (role === "kontributor") {
await handleCreateUserKontributor(e);
await handleCreateUserUmum(e);
return;
}
@ -104,17 +102,6 @@ export default function SignUp() {
setStep("otp");
};
useEffect(() => {
if (role === "kontributor" && firstNameKontributor && lastNameKontributor) {
const generatedUsername = `${firstNameKontributor}-${lastNameKontributor}`
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
setUsernameKontributor((prev) => prev || generatedUsername);
}
}, [role, firstNameKontributor, lastNameKontributor]);
const handleVerifyOtp = (e: React.FormEvent) => {
e.preventDefault();
const code = otp.join("");
@ -132,11 +119,7 @@ export default function SignUp() {
if (value && nextInput) nextInput.focus();
};
const validateName = (value: string) => {
const nameRegex = /^[A-Za-z\s]+$/;
return nameRegex.test(value.trim());
};
// Form validation functions
const validateEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
@ -151,65 +134,6 @@ export default function SignUp() {
return password.length >= 8;
};
const validateUsername = (username: string) => {
const usernameRegex = /^[a-z0-9_-]+$/;
return usernameRegex.test(username);
};
const handleCreateUserKontributor = async (e: React.FormEvent) => {
e.preventDefault();
if (!firstNameKontributor.trim() || !lastNameKontributor.trim()) {
MySwal.fire(
"Peringatan",
"Nama depan dan belakang wajib diisi",
"warning",
);
return;
}
if (!validateEmail(email)) {
MySwal.fire("Peringatan", "Email tidak valid", "warning");
return;
}
if (!validatePassword(kontributorPassword)) {
MySwal.fire("Peringatan", "Password minimal 8 karakter", "warning");
return;
}
const fullName = `${firstNameKontributor} ${lastNameKontributor}`;
const payload = {
address: "",
clientId: "78356d32-52fa-4dfc-b836-6cebf4e3eead",
email,
fullName,
password: kontributorPassword,
phoneNumber: whatsappKontributor,
// username: fullName.toLowerCase().replace(/\s+/g, "-"),
username: usernameKontributor,
userLevelId: 1,
userRoleId: 5, // MISAL role kontributor
};
try {
setIsLoading(true);
const res = await createUser(payload);
if (res?.error) {
MySwal.fire("Gagal", res?.message || "Gagal mendaftar", "error");
} else {
MySwal.fire("Berhasil", "Akun kontributor berhasil dibuat", "success");
router.push("/auth");
}
} catch (err) {
MySwal.fire("Error", "Terjadi kesalahan server", "error");
} finally {
setIsLoading(false);
}
};
const handleCreateUserUmum = async (e: React.FormEvent) => {
e.preventDefault();
@ -283,29 +207,12 @@ export default function SignUp() {
const validateTenantForm = () => {
const errors: any = {};
// if (!firstName.trim()) {
// errors.firstName = "First name is required";
// }
if (!firstName.trim()) {
errors.firstName = "First name wajib diisi";
} else if (!validateName(firstName)) {
errors.firstName = "First name hanya boleh huruf dan spasi";
errors.firstName = "First name is required";
}
// if (!lastName.trim()) {
// errors.lastName = "Last name is required";
// }
if (!lastName.trim()) {
errors.lastName = "Last name wajib diisi";
} else if (!validateName(lastName)) {
errors.lastName = "Last name hanya boleh huruf dan spasi";
}
if (!tenantUsername.trim()) {
errors.tenantUsername = "Username wajib diisi";
} else if (!validateUsername(tenantUsername)) {
errors.tenantUsername =
"Username hanya boleh huruf kecil, angka, tanpa spasi";
errors.lastName = "Last name is required";
}
if (!email.trim()) {
@ -343,7 +250,9 @@ export default function SignUp() {
const handleTenantRegistration = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateTenantForm()) return;
if (!validateTenantForm()) {
return;
}
setIsLoading(true);
setFormErrors({});
@ -352,75 +261,43 @@ export default function SignUp() {
const registrationData = {
adminUser: {
address: "Jakarta",
email: email.trim(),
fullname: `${firstName.trim()} ${lastName.trim()}`,
email: email,
fullname: `${firstName} ${lastName}`,
password: tenantPassword,
phoneNumber: whatsapp.trim(),
username: tenantUsername.trim(),
phoneNumber: whatsapp,
username: `${firstName}-${lastName}`,
},
client: {
clientType: "sub_client",
name: namaTenant.trim(),
name: namaTenant,
parentClientId: "78356d32-52fa-4dfc-b836-6cebf4e3eead",
},
};
const response = await registerTenant(registrationData);
console.log("📦 registerTenant response:", response);
/**
* KUNCI UTAMA
* Backend gagal error === true
*/
if (response.error === true) {
const backendMessage =
typeof response.message === "string"
? response.message
: JSON.stringify(response.message);
// 🔴 Duplicate tenant / slug
if (
backendMessage.includes("clients_slug_key") ||
backendMessage.toLowerCase().includes("duplicate")
) {
MySwal.fire({
title: "Tenant Sudah Terdaftar",
text: "Nama tenant sudah digunakan. Silakan gunakan nama tenant lain.",
icon: "warning",
confirmButtonText: "OK",
});
return;
}
// 🔴 General error
if (response.error) {
MySwal.fire({
title: "Registrasi Gagal",
text: backendMessage || "Tenant gagal dibuat",
title: "Registration Failed",
text: response.message || "An error occurred during registration",
icon: "error",
confirmButtonText: "OK",
});
return;
} else {
MySwal.fire({
title: "Registration Successful",
text: "Your tenant account has been created successfully!",
icon: "success",
confirmButtonText: "OK",
}).then(() => {
router.push("/auth");
});
}
/**
* SUCCESS
* HANYA jika error === false
*/
await MySwal.fire({
title: "Registrasi Berhasil 🎉",
text: "Akun tenant berhasil dibuat. Silakan login.",
icon: "success",
confirmButtonText: "OK",
});
router.push("/auth");
} catch (err) {
console.error("❌ Tenant registration error:", err);
} catch (error) {
console.error("Registration error:", error);
MySwal.fire({
title: "Terjadi Kesalahan",
text: "Terjadi kesalahan server. Silakan coba lagi.",
title: "Registration Failed",
text: "An unexpected error occurred. Please try again.",
icon: "error",
confirmButtonText: "OK",
});
@ -429,66 +306,6 @@ export default function SignUp() {
}
};
// const handleTenantRegistration = async (e: React.FormEvent) => {
// e.preventDefault();
// if (!validateTenantForm()) {
// return;
// }
// setIsLoading(true);
// setFormErrors({});
// try {
// const registrationData = {
// adminUser: {
// address: "Jakarta",
// email: email,
// fullname: `${firstName} ${lastName}`,
// password: tenantPassword,
// phoneNumber: whatsapp,
// // username: `${firstName}-${lastName}`,
// username: tenantUsername,
// },
// client: {
// clientType: "sub_client",
// name: namaTenant,
// parentClientId: "78356d32-52fa-4dfc-b836-6cebf4e3eead",
// },
// };
// const response = await registerTenant(registrationData);
// if (response.error) {
// MySwal.fire({
// title: "Registration Failed",
// text: response.message || "An error occurred during registration",
// icon: "error",
// confirmButtonText: "OK",
// });
// } else {
// MySwal.fire({
// title: "Registration Successful",
// text: "Your tenant account has been created successfully!",
// icon: "success",
// confirmButtonText: "OK",
// }).then(() => {
// router.push("/auth");
// });
// }
// } catch (error) {
// console.error("Registration error:", error);
// MySwal.fire({
// title: "Registration Failed",
// text: "An unexpected error occurred. Please try again.",
// icon: "error",
// confirmButtonText: "OK",
// });
// } finally {
// setIsLoading(false);
// }
// };
// Generate username otomatis dari nama lengkap
React.useEffect(() => {
if (fullname.trim()) {
@ -708,18 +525,6 @@ export default function SignUp() {
/>
</div>
<Input
type="text"
required
placeholder="Username"
value={usernameKontributor}
onChange={(e) => setUsernameKontributor(e.target.value)}
/>
<p className="text-xs text-gray-500">
Username dibuat otomatis dari nama, tetapi dapat diubah.
</p>
<Input
type="email"
required
@ -736,13 +541,13 @@ export default function SignUp() {
onChange={(e) => setWhatsappKontributor(e.target.value)}
/>
{/* <Input
<Input
type="text"
required
placeholder="Nama Perusahaan"
value={namaPerusahaan}
onChange={(e) => setNamaPerusahaan(e.target.value)}
/> */}
/>
<select
required
@ -827,9 +632,6 @@ export default function SignUp() {
formErrors.firstName ? "border-red-500" : ""
}
/>
<p className="text-[10px] text-gray-500 mt-1">
Hanya huruf dan spasi.
</p>
{formErrors.firstName && (
<p className="text-red-500 text-xs mt-1">
{formErrors.firstName}
@ -855,32 +657,6 @@ export default function SignUp() {
</div>
</div>
<div>
<Input
type="text"
required
placeholder="Username"
value={tenantUsername}
onChange={(e) =>
setTenantUsername(e.target.value.toLowerCase())
}
className={
formErrors.tenantUsername ? "border-red-500" : ""
}
/>
<p className="text-xs text-gray-500 mt-1">
Username harus huruf kecil, tanpa spasi. Contoh:{" "}
<b>netidhub-admin</b>
</p>
{formErrors.tenantUsername && (
<p className="text-red-500 text-xs mt-1">
{formErrors.tenantUsername}
</p>
)}
</div>
<div>
<Input
type="email"
@ -1078,7 +854,7 @@ export default function SignUp() {
{/* Link Login */}
<p className="text-center text-sm mt-4">
Sudah punya akun?{" "}
<a href="/auth" className="text-[#007AFF] hover:underline">
<a href="/login" className="text-[#007AFF] hover:underline">
Login
</a>
</p>

View File

@ -8,42 +8,29 @@ import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import { errorAutoClose, loading, successAutoClose } from "@/lib/swal";
import { close } from "@/config/swal";
import {
createUser,
updateUser,
getUserDetail,
import {
createUser,
updateUser,
getUserDetail,
CreateUserRequest,
UpdateUserRequest,
UpdateUserRequest
} from "@/service/management-user/management-user";
import { getInfoProfile } from "@/service/auth";
import { getUserLevels } from "@/service/approval-workflows";
import { Eye, EyeOff } from "lucide-react";
const createUserSchema = z.object({
address: z.string().trim().min(1, { message: "Address wajib diisi" }),
clientId: z.string().trim().min(1, { message: "Client ID wajib diisi" }),
dateOfBirth: z
.string()
.trim()
.min(1, { message: "Date of Birth wajib diisi" }),
dateOfBirth: z.string().trim().min(1, { message: "Date of Birth wajib diisi" }),
email: z.string().email({ message: "Email tidak valid" }),
fullname: z.string().trim().min(1, { message: "Full Name wajib diisi" }),
password: z.string().min(6, { message: "Password minimal 6 karakter" }),
phoneNumber: z
.string()
.trim()
.min(1, { message: "Phone Number wajib diisi" }),
phoneNumber: z.string().trim().min(1, { message: "Phone Number wajib diisi" }),
userLevelId: z.number({ invalid_type_error: "User Level harus dipilih" }),
username: z.string().trim().min(1, { message: "Username wajib diisi" }),
});
@ -51,20 +38,11 @@ const createUserSchema = z.object({
const editUserSchema = z.object({
address: z.string().trim().min(1, { message: "Address wajib diisi" }),
clientId: z.string().trim().min(1, { message: "Client ID wajib diisi" }),
dateOfBirth: z
.string()
.trim()
.min(1, { message: "Date of Birth wajib diisi" }),
dateOfBirth: z.string().trim().min(1, { message: "Date of Birth wajib diisi" }),
email: z.string().email({ message: "Email tidak valid" }),
fullname: z.string().trim().min(1, { message: "Full Name wajib diisi" }),
password: z
.string()
.min(6, { message: "Password minimal 6 karakter" })
.optional(),
phoneNumber: z
.string()
.trim()
.min(1, { message: "Phone Number wajib diisi" }),
password: z.string().min(6, { message: "Password minimal 6 karakter" }).optional(),
phoneNumber: z.string().trim().min(1, { message: "Phone Number wajib diisi" }),
userLevelId: z.number({ invalid_type_error: "User Level harus dipilih" }),
username: z.string().trim().min(1, { message: "Username wajib diisi" }),
});
@ -89,11 +67,8 @@ export default function UserForm({
}: UserFormProps) {
const MySwal = withReactContent(Swal);
const [loadingData, setLoadingData] = useState(false);
const [userLevels, setUserLevels] = useState<{ id: number; name: string }[]>(
[],
);
const [userLevels, setUserLevels] = useState<{id: number; name: string}[]>([]);
const [currentClientId, setCurrentClientId] = useState<string>("");
const [showPassword, setShowPassword] = useState(false);
const {
control,
@ -101,9 +76,7 @@ export default function UserForm({
setValue,
formState: { errors },
} = useForm<UserSchema>({
resolver: zodResolver(
mode === "create" ? createUserSchema : editUserSchema,
),
resolver: zodResolver(mode === "create" ? createUserSchema : editUserSchema),
defaultValues: {
address: "",
clientId: "",
@ -133,8 +106,7 @@ export default function UserForm({
// Get clientId from current user info
if (!userInfoResponse?.error && userInfoResponse?.data?.data) {
const userInfo = userInfoResponse.data.data;
const clientId =
userInfo.instituteId || "78356d32-52fa-4dfc-b836-6cebf4e3eead"; // fallback to default
const clientId = userInfo.instituteId || "78356d32-52fa-4dfc-b836-6cebf4e3eead"; // fallback to default
setCurrentClientId(clientId);
setValue("clientId", clientId);
} else {
@ -160,10 +132,7 @@ export default function UserForm({
setValue("username", user.username || "");
// Don't set password for edit mode
} else {
console.error(
"Gagal mengambil detail user:",
userResponse?.message || "Unknown error",
);
console.error("Gagal mengambil detail user:", userResponse?.message || "Unknown error");
}
} catch (error) {
console.error("Error loading user detail:", error);
@ -192,7 +161,7 @@ export default function UserForm({
password: data.password || "",
phoneNumber: data.phoneNumber,
userLevelId: data.userLevelId,
userRoleId: 3,
userRoleId: 3, // Hardcoded as per requirement
username: data.username,
};
@ -208,25 +177,18 @@ export default function UserForm({
close();
if (response?.error) {
errorAutoClose(
response.message ||
`Gagal ${mode === "edit" ? "memperbarui" : "menyimpan"} data.`,
);
errorAutoClose(response.message || `Gagal ${mode === "edit" ? "memperbarui" : "menyimpan"} data.`);
return;
}
successAutoClose(
`Data berhasil ${mode === "edit" ? "diperbarui" : "disimpan"}.`,
);
successAutoClose(`Data berhasil ${mode === "edit" ? "diperbarui" : "disimpan"}.`);
setTimeout(() => {
if (onSuccess) onSuccess();
}, 3000);
} catch (err) {
close();
errorAutoClose(
`Terjadi kesalahan saat ${mode === "edit" ? "memperbarui" : "menyimpan"} data.`,
);
errorAutoClose(`Terjadi kesalahan saat ${mode === "edit" ? "memperbarui" : "menyimpan"} data.`);
console.error("User operation error:", err);
}
};
@ -299,41 +261,6 @@ export default function UserForm({
{/* Password - Only show for create mode */}
{mode === "create" && (
<div>
<Label>Password</Label>
<div className="relative">
<Controller
control={control}
name="password"
render={({ field }) => (
<Input
{...field}
type={showPassword ? "text" : "password"}
placeholder="Masukkan password"
className="pr-10"
/>
)}
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
{errors.password && (
<p className="text-red-500 text-sm mt-1">
{errors.password.message}
</p>
)}
</div>
)}
{/* {mode === "create" && (
<div>
<Label>Password</Label>
<Controller
@ -347,7 +274,7 @@ export default function UserForm({
<p className="text-red-500 text-sm">{errors.password.message}</p>
)}
</div>
)} */}
)}
{/* Phone Number */}
<div>
@ -360,9 +287,7 @@ export default function UserForm({
)}
/>
{errors.phoneNumber && (
<p className="text-red-500 text-sm">
{errors.phoneNumber.message}
</p>
<p className="text-red-500 text-sm">{errors.phoneNumber.message}</p>
)}
</div>
@ -387,17 +312,17 @@ export default function UserForm({
<Controller
control={control}
name="dateOfBirth"
render={({ field }) => <Input {...field} type="date" />}
render={({ field }) => (
<Input {...field} type="date" />
)}
/>
{errors.dateOfBirth && (
<p className="text-red-500 text-sm">
{errors.dateOfBirth.message}
</p>
<p className="text-red-500 text-sm">{errors.dateOfBirth.message}</p>
)}
</div>
{/* Client ID */}
{/* <div>
<div>
<Label>Client ID</Label>
<Controller
control={control}
@ -417,7 +342,7 @@ export default function UserForm({
<p className="text-xs text-gray-500 mt-1">
Client ID diambil dari profil pengguna yang sedang login
</p>
</div> */}
</div>
{/* User Level */}
<div>
@ -426,10 +351,7 @@ export default function UserForm({
control={control}
name="userLevelId"
render={({ field }) => (
<Select
onValueChange={(value) => field.onChange(Number(value))}
value={field.value?.toString() || ""}
>
<Select onValueChange={(value) => field.onChange(Number(value))} value={field.value?.toString() || ""}>
<SelectTrigger>
<SelectValue placeholder="Pilih user level" />
</SelectTrigger>
@ -450,16 +372,16 @@ export default function UserForm({
)}
/>
{errors.userLevelId && (
<p className="text-red-500 text-sm">
{errors.userLevelId.message}
</p>
<p className="text-red-500 text-sm">{errors.userLevelId.message}</p>
)}
</div>
</div>
{/* Action Buttons */}
<div className="mt-6 flex justify-end gap-3">
<Button type="submit">{mode === "edit" ? "Update" : "Create"}</Button>
<Button type="submit">
{mode === "edit" ? "Update" : "Create"}
</Button>
<Button type="button" variant="outline" onClick={() => onCancel?.()}>
Cancel
</Button>

View File

@ -2938,63 +2938,6 @@ export const UsersIcon = ({ size = 24, width, height, ...props }: IconSvgProps)
</svg>
);
export const MenuIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
);
export const ModuleIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
);
export const EditIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || width}
height={size || height}
{...props}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
export const WorkflowIcon = ({ size = 24, width, height, ...props }: IconSvgProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
@ -3052,4 +2995,35 @@ export const RotateCcwIcon = ({ size = 24, width, height, ...props }: IconSvgPro
<path d="M1 4v6h6" />
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
</svg>
);
export const EditIcon = ({
size = 24,
width,
height,
...props
}: IconSvgProps) => (
<svg
width={size || width}
height={size || height}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);

View File

@ -6,7 +6,6 @@ import {
ArticleCategory,
} from "@/service/categories/article-categories";
import { useTranslations } from "next-intl";
import { RevealR } from "../ui/RevealR";
export default function Category() {
const t = useTranslations("MediaUpdate");
@ -22,7 +21,7 @@ export default function Category() {
// Filter hanya kategori yang aktif dan published
const activeCategories = response.data.data.filter(
(category: ArticleCategory) =>
category.isActive && category.isPublish,
category.isActive && category.isPublish
);
setCategories(activeCategories);
}
@ -56,58 +55,56 @@ export default function Category() {
categories.length > 0 ? categories : fallbackCategories;
return (
<RevealR>
<section className="px-4 py-10">
<div className="max-w-[1350px] mx-auto bg-white dark:bg-default-50 dark:border dark:border-slate-50 rounded-xl shadow-md p-6">
<h2 className="text-xl font-semibold mb-5">
{loading
? t("loadCategory")
: `${displayCategories.length} ${t("category")}`}
</h2>
<section className="px-4 py-10">
<div className="max-w-[1350px] mx-auto bg-white rounded-xl shadow-md p-6">
<h2 className="text-xl font-semibold mb-5">
{loading
? t("loadCategory")
: `${displayCategories.length} ${t("category")}`}
</h2>
{loading ? (
// Loading skeleton
<div className="flex flex-wrap gap-3">
{Array.from({ length: 10 }).map((_, index) => (
<div
{loading ? (
// Loading skeleton
<div className="flex flex-wrap gap-3">
{Array.from({ length: 10 }).map((_, index) => (
<div
key={index}
className="px-4 py-2 rounded border border-gray-200 bg-gray-100 animate-pulse"
>
<div className="h-4 w-20 bg-gray-300 rounded"></div>
</div>
))}
</div>
) : (
<div className="flex flex-wrap gap-3">
{displayCategories.map((category, index) => {
// Handle both API data and fallback data
const categoryTitle =
typeof category === "string" ? category : category.title;
const categorySlug =
typeof category === "string"
? category.toLowerCase().replace(/\s+/g, "-")
: category.slug;
return (
<button
key={index}
className="px-4 py-2 rounded border border-gray-200 bg-gray-100 animate-pulse"
className="px-4 py-2 rounded border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-100 hover:border-gray-400 transition-all duration-200"
onClick={() => {
// Navigate to category page or search by category
console.log(
`Category clicked: ${categoryTitle} (${categorySlug})`
);
// TODO: Implement navigation to category page
}}
>
<div className="h-4 w-20 bg-gray-300 rounded"></div>
</div>
))}
</div>
) : (
<div className="flex flex-wrap gap-3">
{displayCategories.map((category, index) => {
// Handle both API data and fallback data
const categoryTitle =
typeof category === "string" ? category : category.title;
const categorySlug =
typeof category === "string"
? category.toLowerCase().replace(/\s+/g, "-")
: category.slug;
return (
<button
key={index}
className="px-4 py-2 rounded border border-gray-300 text-gray-700 dark:text-white text-sm font-medium hover:bg-gray-100 hover:border-gray-400 transition-all duration-200"
onClick={() => {
// Navigate to category page or search by category
console.log(
`Category clicked: ${categoryTitle} (${categorySlug})`,
);
// TODO: Implement navigation to category page
}}
>
{categoryTitle}
</button>
);
})}
</div>
)}
</div>
</section>
</RevealR>
{categoryTitle}
</button>
);
})}
</div>
)}
</div>
</section>
);
}

View File

@ -13,7 +13,6 @@ import "swiper/css";
import "swiper/css/navigation";
import LocalSwitcher from "../partials/header/locale-switcher";
import { useTranslations } from "next-intl";
import { Reveal } from "./Reveal";
// Custom styles for Swiper
const swiperStyles = `
@ -101,180 +100,178 @@ export default function Footer() {
}, []);
return (
<Reveal>
<footer className="border-t bg-white dark:bg-default-50 text-center">
<style jsx>{swiperStyles}</style>
<div className="max-w-[1350px] mx-auto">
<div className="py-6">
<h2 className="text-2xl font-semibold mb-4 px-4 md:px-0">
{t("publication")}
</h2>
<div className="px-4 md:px-12">
<Swiper
modules={[Navigation, Autoplay]}
spaceBetween={24}
slidesPerView="auto"
centeredSlides={clients.length <= 4}
navigation={{
nextEl: ".swiper-button-next",
prevEl: ".swiper-button-prev",
}}
autoplay={{
delay: 3000,
disableOnInteraction: false,
}}
loop={clients.length > 4}
className={`client-swiper ${
clients.length <= 4 ? "swiper-centered" : ""
}`}
>
{loading
? // Loading skeleton
Array.from({ length: 8 }).map((_, idx) => (
<SwiperSlide key={idx} className="!w-auto">
<div className="w-[80px] h-[80px] md:w-[100px] md:h-[100px] bg-gray-200 rounded animate-pulse" />
</SwiperSlide>
))
: clients.length > 0
? // Dynamic clients from API
clients.map((client, idx) => (
<SwiperSlide key={idx} className="!w-auto">
<a
href={`/in/tenant/${client.slug}`}
target="_blank"
rel="noopener noreferrer"
className="group block"
>
{client.logoUrl ? (
<Image
src={client.logoUrl}
alt={client.name}
width={100}
height={100}
className="md:w-[100px] md:h-[100px] object-contain hover:opacity-80 transition"
/>
) : (
// Fallback when no logo - menggunakan placeholder image
<div className="w-[80px] h-[80px] md:w-[100px] md:h-[100px] rounded flex items-center justify-center hover:from-blue-200 hover:to-blue-300 transition-all duration-200">
<Image
src="/logo-netidhub.png"
alt={`${client.name} placeholder`}
width={100}
height={100}
className="md:w-[100px] md:h-[100px] object-contain opacity-70"
/>
</div>
)}
</a>
</SwiperSlide>
))
: // Fallback to static logos if API fails or no data
logos.map((logo, idx) => (
<SwiperSlide key={idx} className="!w-auto">
<a
href={`/in/tenant/${logo.slug}`}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<footer className="border-t bg-white text-center">
<style jsx>{swiperStyles}</style>
<div className="max-w-[1350px] mx-auto">
<div className="py-6">
<h2 className="text-2xl font-semibold mb-4 px-4 md:px-0">
{t("publication")}
</h2>
<div className="px-4 md:px-12">
<Swiper
modules={[Navigation, Autoplay]}
spaceBetween={24}
slidesPerView="auto"
centeredSlides={clients.length <= 4}
navigation={{
nextEl: ".swiper-button-next",
prevEl: ".swiper-button-prev",
}}
autoplay={{
delay: 3000,
disableOnInteraction: false,
}}
loop={clients.length > 4}
className={`client-swiper ${
clients.length <= 4 ? "swiper-centered" : ""
}`}
>
{loading
? // Loading skeleton
Array.from({ length: 8 }).map((_, idx) => (
<SwiperSlide key={idx} className="!w-auto">
<div className="w-[80px] h-[80px] md:w-[100px] md:h-[100px] bg-gray-200 rounded animate-pulse" />
</SwiperSlide>
))
: clients.length > 0
? // Dynamic clients from API
clients.map((client, idx) => (
<SwiperSlide key={idx} className="!w-auto">
<a
href={`/in/tenant/${client.slug}`}
target="_blank"
rel="noopener noreferrer"
className="group block"
>
{client.logoUrl ? (
<Image
src={client.logoUrl}
alt={client.name}
width={100}
height={100}
className="md:w-[100px] md:h-[100px] object-contain hover:opacity-80 transition"
/>
) : (
// Fallback when no logo - menggunakan placeholder image
<div className="w-[80px] h-[80px] md:w-[100px] md:h-[100px] rounded flex items-center justify-center hover:from-blue-200 hover:to-blue-300 transition-all duration-200">
<Image
src={logo.src}
alt={`logo-${idx}`}
width={80}
height={80}
className="md:w-[100px] md:h-[100px] object-contain hover:opacity-80 transition"
src="/logo-netidhub.png"
alt={`${client.name} placeholder`}
width={100}
height={100}
className="md:w-[100px] md:h-[100px] object-contain opacity-70"
/>
</a>
</SwiperSlide>
))}
</Swiper>
</div>
)}
</a>
</SwiperSlide>
))
: // Fallback to static logos if API fails or no data
logos.map((logo, idx) => (
<SwiperSlide key={idx} className="!w-auto">
<a
href={`/in/tenant/${logo.slug}`}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<Image
src={logo.src}
alt={`logo-${idx}`}
width={80}
height={80}
className="md:w-[100px] md:h-[100px] object-contain hover:opacity-80 transition"
/>
</a>
</SwiperSlide>
))}
</Swiper>
{/* Navigation Buttons */}
{/* <div className="swiper-button-prev !text-gray-600 !w-8 !h-8 !mt-0 !top-1/2 !left-0 !-translate-y-1/2"></div> */}
{/* <div className="swiper-button-next !text-gray-600 !w-8 !h-8 !mt-0 !top-1/2 !right-0 !-translate-y-1/2"></div> */}
</div>
</div>
<div className="border-t my-6 w-full max-w-6xl mx-auto" />
<div className="flex flex-col md:flex-row items-center justify-between gap-4 px-4 pb-6 max-w-6xl mx-auto text-sm text-gray-600 dark:text-white">
<div className="flex items-center gap-2">
<span>ver 1.0.0 @2025 - {t("netidhub")}</span>
<Image
src="/qudo.png"
alt="qudoco"
width={80}
height={80}
className="object-contain"
/>
</div>
{/* Social Media */}
<div className="flex gap-3 text-gray-800 dark:text-white">
<Instagram className="hover:text-black" />
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M14 13.5h2.5l1-4H14v-2c0-1.03 0-2 2-2h1.5V2.14c-.326-.043-1.557-.14-2.857-.14C11.928 2 10 3.657 10 6.7v2.8H7v4h3V22h4z"
/>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g fill="none">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M4.594 4.984a1 1 0 0 1 .941.429C7.011 7.572 8.783 8.47 10.75 8.674c.096-.841.323-1.672.75-2.404c.626-1.074 1.644-1.864 3.098-2.156c2.01-.404 3.54.324 4.427 1.215l1.792-.335a1 1 0 0 1 1.053 1.478l-1.72 3.022c.157 4.361-1.055 7.405-3.639 9.502c-1.37 1.112-3.332 1.743-5.485 1.938c-2.17.196-4.623-.041-7.061-.753a1 1 0 0 1 .007-1.922c1.226-.349 2.16-.65 3.003-1.177c-1.199-.636-2.082-1.468-2.707-2.416c-.868-1.318-1.19-2.788-1.254-4.113S3.141 8 3.343 7.115c.115-.505.249-1.011.434-1.495a1 1 0 0 1 .818-.636Z"
/>
</g>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
// fill-rule="evenodd"
d="M9.935 14.628v-5.62l5.403 2.82zM21.8 8.035s-.195-1.379-.795-1.986c-.76-.796-1.613-.8-2.004-.847C16.203 5 12.004 5 12.004 5h-.008s-4.198 0-6.997.202c-.391.047-1.243.05-2.004.847c-.6.607-.795 1.986-.795 1.986S2 9.653 2 11.272v1.517c0 1.618.2 3.237.2 3.237s.195 1.378.795 1.985c.76.797 1.76.771 2.205.855c1.6.153 6.8.2 6.8.2s4.203-.006 7.001-.208c.391-.047 1.244-.05 2.004-.847c.6-.607.795-1.985.795-1.985s.2-1.619.2-3.237v-1.517c0-1.619-.2-3.237-.2-3.237"
/>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
// fill-rule="evenodd"
>
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M14 2a2 2 0 0 1 2 2a3.004 3.004 0 0 0 2.398 2.94a2 2 0 0 1-.796 3.92A7 7 0 0 1 16 10.325V16a6 6 0 1 1-7.499-5.81a2 2 0 0 1 .998 3.873A2.002 2.002 0 0 0 10 18a2 2 0 0 0 2-2V4a2 2 0 0 1 2-2"
/>
</g>
</svg>
</div>
{/* button language */}
<div className={`relative text-left border rounded-lg`}>
<LocalSwitcher />
</div>
{/* Navigation Buttons */}
{/* <div className="swiper-button-prev !text-gray-600 !w-8 !h-8 !mt-0 !top-1/2 !left-0 !-translate-y-1/2"></div> */}
{/* <div className="swiper-button-next !text-gray-600 !w-8 !h-8 !mt-0 !top-1/2 !right-0 !-translate-y-1/2"></div> */}
</div>
</div>
</footer>
</Reveal>
<div className="border-t my-6 w-full max-w-6xl mx-auto" />
<div className="flex flex-col md:flex-row items-center justify-between gap-4 px-4 pb-6 max-w-6xl mx-auto text-sm text-gray-600">
<div className="flex items-center gap-2">
<span>ver 1.0.0 @2025 - {t("netidhub")}</span>
<Image
src="/qudo.png"
alt="qudoco"
width={80}
height={80}
className="object-contain"
/>
</div>
{/* Social Media */}
<div className="flex gap-3 text-gray-800">
<Instagram className="hover:text-black" />
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M14 13.5h2.5l1-4H14v-2c0-1.03 0-2 2-2h1.5V2.14c-.326-.043-1.557-.14-2.857-.14C11.928 2 10 3.657 10 6.7v2.8H7v4h3V22h4z"
/>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g fill="none">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M4.594 4.984a1 1 0 0 1 .941.429C7.011 7.572 8.783 8.47 10.75 8.674c.096-.841.323-1.672.75-2.404c.626-1.074 1.644-1.864 3.098-2.156c2.01-.404 3.54.324 4.427 1.215l1.792-.335a1 1 0 0 1 1.053 1.478l-1.72 3.022c.157 4.361-1.055 7.405-3.639 9.502c-1.37 1.112-3.332 1.743-5.485 1.938c-2.17.196-4.623-.041-7.061-.753a1 1 0 0 1 .007-1.922c1.226-.349 2.16-.65 3.003-1.177c-1.199-.636-2.082-1.468-2.707-2.416c-.868-1.318-1.19-2.788-1.254-4.113S3.141 8 3.343 7.115c.115-.505.249-1.011.434-1.495a1 1 0 0 1 .818-.636Z"
/>
</g>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
// fill-rule="evenodd"
d="M9.935 14.628v-5.62l5.403 2.82zM21.8 8.035s-.195-1.379-.795-1.986c-.76-.796-1.613-.8-2.004-.847C16.203 5 12.004 5 12.004 5h-.008s-4.198 0-6.997.202c-.391.047-1.243.05-2.004.847c-.6.607-.795 1.986-.795 1.986S2 9.653 2 11.272v1.517c0 1.618.2 3.237.2 3.237s.195 1.378.795 1.985c.76.797 1.76.771 2.205.855c1.6.153 6.8.2 6.8.2s4.203-.006 7.001-.208c.391-.047 1.244-.05 2.004-.847c.6-.607.795-1.985.795-1.985s.2-1.619.2-3.237v-1.517c0-1.619-.2-3.237-.2-3.237"
/>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g
fill="none"
// fill-rule="evenodd"
>
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M14 2a2 2 0 0 1 2 2a3.004 3.004 0 0 0 2.398 2.94a2 2 0 0 1-.796 3.92A7 7 0 0 1 16 10.325V16a6 6 0 1 1-7.499-5.81a2 2 0 0 1 .998 3.873A2.002 2.002 0 0 0 10 18a2 2 0 0 0 2-2V4a2 2 0 0 1 2-2"
/>
</g>
</svg>
</div>
{/* button language */}
<div className={`relative text-left border rounded-lg`}>
<LocalSwitcher />
</div>
</div>
</div>
</footer>
);
}

View File

@ -18,9 +18,6 @@ import "swiper/css/navigation";
import "swiper/css/pagination";
import ImageBlurry from "../ui/image-blurry";
import { useTranslations } from "next-intl";
import { Reveal } from "../ui/Reveal";
import { RevealL } from "../ui/RevealL";
import { RevealR } from "../ui/RevealR";
const images = ["/PPS.png", "/PPS2.jpeg", "/PPS3.jpg", "/PPS4.png"];
@ -44,7 +41,7 @@ export default function Header() {
undefined,
undefined,
"createdAt",
slug,
slug
);
let articlesData: any[] = [];
@ -59,14 +56,14 @@ export default function Header() {
"createdAt",
"",
"",
"",
""
);
articlesData = (fallbackResponse?.data?.data?.content || []).filter(
(item: any) => item.typeId === 1,
(item: any) => item.typeId === 1
);
} else {
articlesData = (response?.data?.data || []).filter(
(item: any) => item.typeId === 1,
(item: any) => item.typeId === 1
);
}
@ -109,14 +106,14 @@ export default function Header() {
const ids = new Set<number>(
(Array.isArray(bookmarks) ? bookmarks : [])
.map((b: any) => Number(b.articleId ?? b.id ?? b.article?.id))
.filter((x) => !isNaN(x)),
.filter((x) => !isNaN(x))
);
const merged = new Set([...localSet, ...ids]);
setBookmarkedIds(merged);
localStorage.setItem(
"bookmarkedIds",
JSON.stringify(Array.from(merged)),
JSON.stringify(Array.from(merged))
);
}
} catch (error) {
@ -131,77 +128,75 @@ export default function Header() {
if (bookmarkedIds.size > 0) {
localStorage.setItem(
"bookmarkedIds",
JSON.stringify(Array.from(bookmarkedIds)),
JSON.stringify(Array.from(bookmarkedIds))
);
}
}, [bookmarkedIds]);
return (
<RevealR>
<section className="max-w-[1350px] mx-auto px-4">
<div className="flex flex-col lg:flex-row gap-6 py-6">
{data.length > 0 && (
<section className="max-w-[1350px] mx-auto px-4">
<div className="flex flex-col lg:flex-row gap-6 py-6">
{data.length > 0 && (
<Card
item={data[0]}
isBig
isInitiallyBookmarked={bookmarkedIds.has(Number(data[0].id))}
onSaved={(id) =>
setBookmarkedIds((prev) => new Set([...prev, Number(id)]))
}
/>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 w-full">
{data.slice(1, 5).map((item) => (
<Card
item={data[0]}
isBig
isInitiallyBookmarked={bookmarkedIds.has(Number(data[0].id))}
key={item.id}
item={item}
isInitiallyBookmarked={bookmarkedIds.has(Number(item.id))}
onSaved={(id) =>
setBookmarkedIds((prev) => new Set([...prev, Number(id)]))
}
/>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 w-full">
{data.slice(1, 5).map((item) => (
<Card
key={item.id}
item={item}
isInitiallyBookmarked={bookmarkedIds.has(Number(item.id))}
onSaved={(id) =>
setBookmarkedIds((prev) => new Set([...prev, Number(id)]))
}
/>
))}
</div>
))}
</div>
</div>
<div className="relative w-full h-48 sm:h-64 md:h-80 lg:h-[460px] mt-4 rounded-xl overflow-hidden">
<Swiper
modules={[Navigation, Pagination]}
navigation
pagination={{ clickable: true }}
spaceBetween={10}
slidesPerView={1}
loop={true}
className="w-full h-full"
>
{images.map((img, index) => (
<SwiperSlide key={index}>
<div className="relative w-full h-48 sm:h-64 md:h-80 lg:h-[460px]">
{/* <Image
<div className="relative w-full h-48 sm:h-64 md:h-80 lg:h-[460px] mt-4 rounded-xl overflow-hidden">
<Swiper
modules={[Navigation, Pagination]}
navigation
pagination={{ clickable: true }}
spaceBetween={10}
slidesPerView={1}
loop={true}
className="w-full h-full"
>
{images.map((img, index) => (
<SwiperSlide key={index}>
<div className="relative w-full h-48 sm:h-64 md:h-80 lg:h-[460px]">
{/* <Image
src={img}
alt={`slide-${index}`}
fill
className="object-cover rounded-xl"
priority={index === 0}
/> */}
<ImageBlurry
priority
src={img}
alt="gambar"
style={{
objectFit: "contain",
width: "100%",
height: "100%",
}}
/>
</div>
</SwiperSlide>
))}
</Swiper>
</div>
</section>
</RevealR>
<ImageBlurry
priority
src={img}
alt="gambar"
style={{
objectFit: "contain",
width: "100%",
height: "100%",
}}
/>
</div>
</SwiperSlide>
))}
</Swiper>
</div>
</section>
);
}
@ -221,29 +216,6 @@ function Card({
const MySwal = withReactContent(Swal);
const [isSaving, setIsSaving] = useState(false);
const [isBookmarked, setIsBookmarked] = useState(isInitiallyBookmarked);
const DEFAULT_IMAGE = "/assets/logo1.png";
const [imageSrc, setImageSrc] = useState(DEFAULT_IMAGE);
useEffect(() => {
const src = item?.smallThumbnailLink;
if (!src) {
setImageSrc(DEFAULT_IMAGE);
return;
}
const img = new window.Image();
img.src = src;
img.onload = () => {
setImageSrc(src);
};
img.onerror = () => {
setImageSrc(DEFAULT_IMAGE);
};
}, [item?.smallThumbnailLink]);
useEffect(() => {
setIsBookmarked(isInitiallyBookmarked);
@ -285,7 +257,7 @@ function Card({
newSet.add(Number(item.id));
localStorage.setItem(
"bookmarkedIds",
JSON.stringify(Array.from(newSet)),
JSON.stringify(Array.from(newSet))
);
MySwal.fire({
@ -311,9 +283,9 @@ function Card({
};
return (
<RevealL>
<div>
<div
className={`rounded-xl overflow-hidden shadow hover:shadow-lg transition-all bg-white dark:bg-black dark:border dark:border-slate-50 ${
className={`rounded-xl overflow-hidden shadow hover:shadow-lg transition-all bg-white ${
isBig
? "w-full lg:max-w-[670px] lg:min-h-[680px]"
: "w-full h-[350px] md:h-[330px]"
@ -325,22 +297,17 @@ function Card({
} w-full`}
>
<Link href={getLink()}>
{/* <Image
<Image
src={item.smallThumbnailLink || "/contributor.png"}
alt={item.title}
fill
className="object-cover"
/> */}
<ImageBlurry
src={imageSrc}
alt={item.title}
className="w-full h-full object-contain"
/>
</Link>
</div>
<div className="py-[26px] px-4 space-y-2">
<div className="flex justify-between items-center gap-2 text-xs font-semibold flex-row">
<div className="flex items-center gap-2 text-xs font-semibold flex-wrap">
<span className="bg-emerald-600 text-white px-2 py-0.5 rounded">
{item.clientName}
</span>
@ -396,7 +363,7 @@ function Card({
</div>
</div>
</div>
</RevealL>
</div>
);
}

View File

@ -17,8 +17,6 @@ import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import { ThumbsUp, ThumbsDown } from "lucide-react";
import { useTranslations } from "next-intl";
import ImageBlurry from "../ui/image-blurry";
import { RevealL } from "../ui/RevealL";
function formatTanggal(dateString: string) {
if (!dateString) return "";
@ -54,8 +52,6 @@ export default function MediaUpdate() {
const slug = params?.slug as string;
const DEFAULT_IMAGE = "/assets/logo1.png";
useEffect(() => {
fetchData(tab);
}, [tab, slug]);
@ -201,16 +197,6 @@ export default function MediaUpdate() {
const typeId = parseInt(getTypeIdByContentType(contentType));
setCurrentTypeId(typeId.toString());
// const response = await listArticles(
// 1,
// 10,
// typeId,
// undefined,
// undefined,
// section === "latest" ? "createdAt" : "viewCount",
// slug
// );
const response = await listArticles(
1,
10,
@ -218,13 +204,13 @@ export default function MediaUpdate() {
undefined,
undefined,
section === "latest" ? "createdAt" : "viewCount",
slug || undefined, // ⬅️ jangan kirim undefined string
slug
);
let hasil: any[] = [];
if (response?.error) {
// console.error("Articles API failed, fallback ke old API");
console.error("Articles API failed, fallback ke old API");
const fallbackRes = await listData(
typeId.toString(),
"",
@ -233,7 +219,7 @@ export default function MediaUpdate() {
0,
"",
"",
"",
""
);
hasil = fallbackRes?.data?.data?.content || [];
} else {
@ -275,14 +261,14 @@ export default function MediaUpdate() {
const ids = new Set<number>(
(Array.isArray(bookmarks) ? bookmarks : [])
.map((b: any) => Number(b.articleId ?? b.id ?? b.article?.id))
.filter((x) => !isNaN(x)),
.filter((x) => !isNaN(x))
);
const gabungan = new Set([...localSet, ...ids]);
setBookmarkedIds(gabungan);
localStorage.setItem(
"bookmarkedIds",
JSON.stringify(Array.from(gabungan)),
JSON.stringify(Array.from(gabungan))
);
}
} catch (err) {
@ -320,7 +306,7 @@ export default function MediaUpdate() {
setBookmarkedIds(updated);
localStorage.setItem(
"bookmarkedIds",
JSON.stringify(Array.from(updated)),
JSON.stringify(Array.from(updated))
);
MySwal.fire({
@ -341,75 +327,44 @@ export default function MediaUpdate() {
}
};
function SafeImage({ src, alt }: { src?: string; alt?: string }) {
const [imgSrc, setImgSrc] = useState(DEFAULT_IMAGE);
useEffect(() => {
if (!src) {
setImgSrc(DEFAULT_IMAGE);
return;
}
const img = new window.Image();
img.src = src;
img.onload = () => {
setImgSrc(src);
};
img.onerror = () => {
setImgSrc(DEFAULT_IMAGE);
};
}, [src]);
return (
<ImageBlurry
src={imgSrc}
alt={alt || "Image"}
className="w-full h-full object-contain"
/>
);
}
return (
<RevealL>
<section className="bg-white dark:bg-default-50 px-4 py-10 border max-w-[1350px] mx-auto rounded-md border-[#CDD5DF] my-10">
<div className="max-w-screen-xl mx-auto">
<h2 className="text-2xl font-semibold text-center mb-6">
{t("title")}
</h2>
<section className="bg-white px-4 py-10 border max-w-[1350px] mx-auto rounded-md border-[#CDD5DF] my-10">
<div className="max-w-screen-xl mx-auto">
<h2 className="text-2xl font-semibold text-center mb-6">
{t("title")}
</h2>
{/* Main Tab */}
<div className="flex justify-center mb-6 bg-white dark:bg-default-50">
<Card className="bg-[#FFFFFF] dark:bg-default-50 rounded-xl flex flex-row p-3 gap-2 shadow-md border border-gray-200">
<button
onClick={() => setTab("latest")}
className={`px-6 py-3 rounded-lg text-sm font-semibold transition-all duration-200 ${
tab === "latest"
? "bg-[#C6A455] text-white shadow-sm"
: "text-[#C6A455] hover:bg-[#C6A455]/10"
}`}
>
{t("latest")}
</button>
<button
onClick={() => setTab("popular")}
className={`px-6 py-3 rounded-lg text-sm font-semibold transition-all duration-200 ${
tab === "popular"
? "bg-[#C6A455] text-white shadow-sm"
: "text-[#C6A455] hover:bg-[#C6A455]/10"
}`}
>
{t("popular")}{" "}
</button>
</Card>
</div>
{/* Main Tab */}
<div className="flex justify-center mb-6 bg-white">
<Card className="bg-[#FFFFFF] rounded-xl flex flex-row p-3 gap-2 shadow-md border border-gray-200">
<button
onClick={() => setTab("latest")}
className={`px-6 py-3 rounded-lg text-sm font-semibold transition-all duration-200 ${
tab === "latest"
? "bg-[#C6A455] text-white shadow-sm"
: "text-[#C6A455] hover:bg-[#C6A455]/10"
}`}
>
{t("latest")}
</button>
<button
onClick={() => setTab("popular")}
className={`px-6 py-3 rounded-lg text-sm font-semibold transition-all duration-200 ${
tab === "popular"
? "bg-[#C6A455] text-white shadow-sm"
: "text-[#C6A455] hover:bg-[#C6A455]/10"
}`}
>
{t("popular")}{" "}
</button>
</Card>
</div>
{/* Content Type Filter */}
<div className="flex justify-center mb-8">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-2xl p-4 border-2 border-blue-100 shadow-lg dark:bg-default-50">
<div className="flex flex-wrap justify-center gap-2">
{/* <button
{/* Content Type Filter */}
<div className="flex justify-center mb-8">
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-2xl p-4 border-2 border-blue-100 shadow-lg">
<div className="flex flex-wrap justify-center gap-2">
{/* <button
onClick={() => setContentType("all")}
className={`px-4 py-2 rounded-full text-xs font-semibold transition-all duration-300 transform hover:scale-105 ${
contentType === "all"
@ -420,181 +375,175 @@ export default function MediaUpdate() {
📋 Semua
</button> */}
<button
onClick={() => setContentType("foto")}
className={`px-4 py-2 rounded-full text-xs font-semibold transition-all duration-300 transform hover:scale-105 ${
contentType === "foto"
? "bg-gradient-to-r from-orange-500 to-red-600 text-white shadow-lg ring-2 ring-orange-300"
: "bg-white text-orange-600 border-2 border-orange-200 hover:border-orange-400 hover:shadow-md"
}`}
>
📸 {t("image")}
</button>
<button
onClick={() => setContentType("audiovisual")}
className={`px-4 py-2 rounded-full text-xs font-semibold transition-all duration-300 transform hover:scale-105 ${
contentType === "audiovisual"
? "bg-gradient-to-r from-purple-500 to-pink-600 text-white shadow-lg ring-2 ring-purple-300"
: "bg-white text-purple-600 border-2 border-purple-200 hover:border-purple-400 hover:shadow-md"
}`}
>
🎬 {t("video")}
</button>
<button
onClick={() => setContentType("audio")}
className={`px-4 py-2 rounded-full text-xs font-semibold transition-all duration-300 transform hover:scale-105 ${
contentType === "audio"
? "bg-gradient-to-r from-green-500 to-emerald-600 text-white shadow-lg ring-2 ring-green-300"
: "bg-white text-green-600 border-2 border-green-200 hover:border-green-400 hover:shadow-md"
}`}
>
🎵 Audio
</button>
<button
onClick={() => setContentType("text")}
className={`px-4 py-2 rounded-full text-xs font-semibold transition-all duration-300 transform hover:scale-105 ${
contentType === "text"
? "bg-gradient-to-r from-gray-500 to-slate-600 text-white shadow-lg ring-2 ring-gray-300"
: "bg-white text-gray-600 border-2 border-gray-200 hover:border-gray-400 hover:shadow-md"
}`}
>
📝 {t("text")}
</button>
</div>
<button
onClick={() => setContentType("foto")}
className={`px-4 py-2 rounded-full text-xs font-semibold transition-all duration-300 transform hover:scale-105 ${
contentType === "foto"
? "bg-gradient-to-r from-orange-500 to-red-600 text-white shadow-lg ring-2 ring-orange-300"
: "bg-white text-orange-600 border-2 border-orange-200 hover:border-orange-400 hover:shadow-md"
}`}
>
📸 {t("image")}
</button>
<button
onClick={() => setContentType("audiovisual")}
className={`px-4 py-2 rounded-full text-xs font-semibold transition-all duration-300 transform hover:scale-105 ${
contentType === "audiovisual"
? "bg-gradient-to-r from-purple-500 to-pink-600 text-white shadow-lg ring-2 ring-purple-300"
: "bg-white text-purple-600 border-2 border-purple-200 hover:border-purple-400 hover:shadow-md"
}`}
>
🎬 {t("video")}
</button>
<button
onClick={() => setContentType("audio")}
className={`px-4 py-2 rounded-full text-xs font-semibold transition-all duration-300 transform hover:scale-105 ${
contentType === "audio"
? "bg-gradient-to-r from-green-500 to-emerald-600 text-white shadow-lg ring-2 ring-green-300"
: "bg-white text-green-600 border-2 border-green-200 hover:border-green-400 hover:shadow-md"
}`}
>
🎵 Audio
</button>
<button
onClick={() => setContentType("text")}
className={`px-4 py-2 rounded-full text-xs font-semibold transition-all duration-300 transform hover:scale-105 ${
contentType === "text"
? "bg-gradient-to-r from-gray-500 to-slate-600 text-white shadow-lg ring-2 ring-gray-300"
: "bg-white text-gray-600 border-2 border-gray-200 hover:border-gray-400 hover:shadow-md"
}`}
>
📝 {t("text")}
</button>
</div>
</div>
</div>
{/* Slider */}
{loading ? (
<p className="text-center">{t("loadContent")}</p>
) : (
<Swiper
modules={[Navigation]}
navigation
spaceBetween={20}
slidesPerView={1}
breakpoints={{
640: { slidesPerView: 2 },
1024: { slidesPerView: 4 },
}}
>
{filteredData.map((item) => (
<SwiperSlide key={item.id}>
<div className="rounded-xl shadow-md overflow-hidden bg-white dark:bg-default-50 dark:border dark:border-slate-50">
{/* ✅ Kondisi: jika typeId = 3 (text) atau 4 (audio) tampilkan ikon, lainnya tampilkan thumbnail */}
{item.typeId === 3 ? (
// 📝 TEXT
<div className="bg-[#e0c350] flex items-center justify-center h-[204px] text-white">
<svg
xmlns="http://www.w3.org/2000/svg"
width="90"
height="90"
viewBox="0 0 16 16"
>
<path
fill="currentColor"
d="M5 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V5.414a1.5 1.5 0 0 0-.44-1.06L9.647 1.439A1.5 1.5 0 0 0 8.586 1zM4 3a1 1 0 0 1 1-1h3v2.5A1.5 1.5 0 0 0 9.5 6H12v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zm7.793 2H9.5a.5.5 0 0 1-.5-.5V2.207z"
/>
</svg>
</div>
) : item.typeId === 4 ? (
// 🎵 AUDIO
<div className="flex items-center justify-center bg-[#bb3523] w-full h-[204px] text-white">
<svg
xmlns="http://www.w3.org/2000/svg"
width="100"
height="100"
viewBox="0 0 20 20"
>
<path
fill="currentColor"
d="M14.702 2.226A1 1 0 0 1 16 3.18v6.027a5.5 5.5 0 0 0-1-.184V6.18L8 8.368V15.5a2.5 2.5 0 1 1-1-2V5.368a1 1 0 0 1 .702-.955zM8 7.32l7-2.187V3.18L8 5.368zM5.5 14a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3m13.5.5a4.5 4.5 0 1 1-9 0a4.5 4.5 0 0 1 9 0m-2.265-.436l-2.994-1.65a.5.5 0 0 0-.741.438v3.3a.5.5 0 0 0 .741.438l2.994-1.65a.5.5 0 0 0 0-.876"
/>
</svg>
</div>
) : (
// 🎬 FOTO / VIDEO (default)
<div className="w-full h-[204px] relative">
<Link href={getLink(item)}>
<SafeImage
src={item.smallThumbnailLink}
alt={item.title}
/>
{/* <Image
{/* Slider */}
{loading ? (
<p className="text-center">{t("loadContent")}</p>
) : (
<Swiper
modules={[Navigation]}
navigation
spaceBetween={20}
slidesPerView={1}
breakpoints={{
640: { slidesPerView: 2 },
1024: { slidesPerView: 4 },
}}
>
{filteredData.map((item) => (
<SwiperSlide key={item.id}>
<div className="rounded-xl shadow-md overflow-hidden bg-white">
{/* ✅ Kondisi: jika typeId = 3 (text) atau 4 (audio) tampilkan ikon, lainnya tampilkan thumbnail */}
{item.typeId === 3 ? (
// 📝 TEXT
<div className="bg-[#e0c350] flex items-center justify-center h-[204px] text-white">
<svg
xmlns="http://www.w3.org/2000/svg"
width="90"
height="90"
viewBox="0 0 16 16"
>
<path
fill="currentColor"
d="M5 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V5.414a1.5 1.5 0 0 0-.44-1.06L9.647 1.439A1.5 1.5 0 0 0 8.586 1zM4 3a1 1 0 0 1 1-1h3v2.5A1.5 1.5 0 0 0 9.5 6H12v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zm7.793 2H9.5a.5.5 0 0 1-.5-.5V2.207z"
/>
</svg>
</div>
) : item.typeId === 4 ? (
// 🎵 AUDIO
<div className="flex items-center justify-center bg-[#bb3523] w-full h-[204px] text-white">
<svg
xmlns="http://www.w3.org/2000/svg"
width="100"
height="100"
viewBox="0 0 20 20"
>
<path
fill="currentColor"
d="M14.702 2.226A1 1 0 0 1 16 3.18v6.027a5.5 5.5 0 0 0-1-.184V6.18L8 8.368V15.5a2.5 2.5 0 1 1-1-2V5.368a1 1 0 0 1 .702-.955zM8 7.32l7-2.187V3.18L8 5.368zM5.5 14a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3m13.5.5a4.5 4.5 0 1 1-9 0a4.5 4.5 0 0 1 9 0m-2.265-.436l-2.994-1.65a.5.5 0 0 0-.741.438v3.3a.5.5 0 0 0 .741.438l2.994-1.65a.5.5 0 0 0 0-.876"
/>
</svg>
</div>
) : (
// 🎬 FOTO / VIDEO (default)
<div className="w-full h-[204px] relative">
<Link href={getLink(item)}>
<Image
src={item.smallThumbnailLink || "/placeholder.png"}
alt={item.title || "No Title"}
fill
className="object-cover cursor-pointer hover:opacity-90 transition-opacity"
/> */}
</Link>
</div>
)}
{/* Caption / info */}
<div className="p-3">
<div className="flex items-center gap-2 text-xs font-semibold flex-row justify-between mb-2">
<span className="text-xs text-white px-2 py-0.5 rounded bg-emerald-600">
{item.clientName}
</span>
<span className="text-orange-600">
{item.categories
?.map((cat: any) => cat.title)
.join(", ")}
</span>
</div>
<p className="text-xs text-gray-500 mb-1">
{formatTanggal(item.createdAt)}
</p>
<Link href={getLink(item)}>
<p className="text-sm font-semibold mb-3 line-clamp-2 cursor-pointer hover:text-blue-600 transition-colors">
{item.title}
</p>
/>
</Link>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex gap-2 text-gray-600">
<ThumbsUp className="w-4 h-4 cursor-pointer" />
<ThumbsDown className="w-4 h-4 cursor-pointer" />
</div>
<Button
onClick={() => handleSave(item.id)}
disabled={bookmarkedIds.has(Number(item.id))}
variant="default"
size="sm"
className={`rounded px-4 ${
bookmarkedIds.has(Number(item.id))
? "bg-gray-400 cursor-not-allowed text-white"
: "bg-red-700 text-white hover:bg-red-500"
}`}
>
{bookmarkedIds.has(Number(item.id))
? t("saved")
: t("save")}
</Button>
{/* Caption / info */}
<div className="p-3">
<div className="flex items-center gap-2 text-xs font-semibold flex-wrap mb-2">
<span className="text-xs text-white px-2 py-0.5 rounded bg-emerald-600">
{item.clientName || "Tanpa Kategori"}
</span>
<span className="text-orange-600">
{item.categories
?.map((cat: any) => cat.title)
.join(", ")}
</span>
</div>
<p className="text-xs text-gray-500 mb-1">
{formatTanggal(item.createdAt)}
</p>
<Link href={getLink(item)}>
<p className="text-sm font-semibold mb-3 line-clamp-2 cursor-pointer hover:text-blue-600 transition-colors">
{item.title}
</p>
</Link>
<div className="flex items-center justify-between">
<div className="flex gap-2 text-gray-600">
<ThumbsUp className="w-4 h-4 cursor-pointer" />
<ThumbsDown className="w-4 h-4 cursor-pointer" />
</div>
<Button
onClick={() => handleSave(item.id)}
disabled={bookmarkedIds.has(Number(item.id))}
variant="default"
size="sm"
className={`rounded px-4 ${
bookmarkedIds.has(Number(item.id))
? "bg-gray-400 cursor-not-allowed text-white"
: "bg-red-700 text-white hover:bg-red-500"
}`}
>
{bookmarkedIds.has(Number(item.id))
? t("saved")
: t("save")}
</Button>
</div>
</div>
</SwiperSlide>
))}
</Swiper>
)}
</div>
</SwiperSlide>
))}
</Swiper>
)}
{/* Lihat lebih banyak - hanya muncul jika ada data */}
{filteredData.length > 0 && (
<div className="text-center mt-10">
<Link href={getContentTypeLink()}>
<Button
size={"lg"}
className="text-[#b3882e] bg-transparent border border-[#b3882e] px-6 py-2 rounded-s-sm text-sm font-medium hover:bg-[#b3882e]/10 transition"
>
{t("seeMore")}
</Button>
</Link>
</div>
)}
</div>
</section>
</RevealL>
{/* Lihat lebih banyak - hanya muncul jika ada data */}
{filteredData.length > 0 && (
<div className="text-center mt-10">
<Link href={getContentTypeLink()}>
<Button
size={"lg"}
className="text-[#b3882e] bg-transparent border border-[#b3882e] px-6 py-2 rounded-s-sm text-sm font-medium hover:bg-[#b3882e]/10 transition"
>
{t("seeMore")}
</Button>
</Link>
</div>
)}
</div>
</section>
);
}

View File

@ -13,8 +13,6 @@ import { Link } from "@/i18n/routing";
import { DynamicLogoTenant } from "./dynamic-logo-tenant";
import { useTranslations } from "next-intl";
import LocalSwitcher from "../partials/header/locale-switcher";
import ThemeSwitcher from "../partials/header/theme-switcher";
import { RevealT } from "../ui/RevealT";
export default function Navbar() {
const t = useTranslations("Navbar");
@ -76,218 +74,211 @@ export default function Navbar() {
const fullname = Cookies.get("ufne");
return (
<RevealT>
<header className="relative max-w-[1400px] mx-auto flex items-center justify-between px-4 py-3 border-b bg-white dark:bg-default-50 z-50">
<div className="flex flex-row items-center justify-between space-x-4 z-10">
<Menu
className="w-6 h-6 cursor-pointer"
onClick={() => setIsSidebarOpen(true)}
<header className="relative max-w-[1400px] mx-auto flex items-center justify-between px-4 py-3 border-b bg-white z-50">
<div className="flex flex-row items-center justify-between space-x-4 z-10">
<Menu
className="w-6 h-6 cursor-pointer"
onClick={() => setIsSidebarOpen(true)}
/>
<Link href="/" className="relative w-32 h-20">
<Image
src="/assets/logo1.png"
alt="Logo"
fill
className="object-contain"
/>
</Link>
<Link href="/" className="relative w-32 h-20">
<Image
src="/assets/logo1.png"
alt="Logo"
fill
className="object-contain"
/>
</Link>
<DynamicLogoTenant />
</div>
<DynamicLogoTenant />
{/* 🌐 NAV MENU */}
<nav className="absolute left-1/2 -translate-x-1/2 hidden md:flex space-x-3 lg:space-x-8 text-sm font-medium">
{filteredNavItems.map((item) => {
const isActive = pathname === item.href;
<div className="hidden custom-lg-button:flex items-end">
<ThemeSwitcher />
</div>
</div>
{/* 🌐 NAV MENU */}
<nav className="absolute left-1/2 -translate-x-1/2 hidden md:flex space-x-3 lg:space-x-8 text-sm font-medium">
{filteredNavItems.map((item) => {
const isActive = pathname === item.href;
// 🔹 Pengecekan khusus untuk "Untuk Anda"
const handleClick = (e: React.MouseEvent) => {
if (item.label === t("forYou")) {
e.preventDefault();
if (!checkLoginStatus()) {
router.push("/auth");
} else {
router.push("/in/for-you");
}
// 🔹 Pengecekan khusus untuk "Untuk Anda"
const handleClick = (e: React.MouseEvent) => {
if (item.label === t("forYou")) {
e.preventDefault();
if (!checkLoginStatus()) {
router.push("/auth");
} else {
router.push("/in/for-you");
}
};
}
};
return (
<div key={item.label} className="relative">
{item.label === t("publication") ? (
<>
<button
onClick={() => setDropdownOpen(!isDropdownOpen)}
className={cn(
"relative text-gray-500 dark:text-white dark:hover:text-slate-300 hover:text-black transition-colors",
isDropdownOpen ||
pathname.startsWith("/public/publication")
? "text-black"
: "",
)}
>
{item.label}
<span
className={cn(
"absolute -bottom-1 left-1/2 -translate-x-1/2 w-6 h-[3px] bg-red-800 rounded transition-all",
isDropdownOpen ||
pathname.startsWith("/public/publication")
? "opacity-100"
: "opacity-0",
)}
/>
</button>
{isDropdownOpen && (
<div className="absolute top-full mt-2 w-48 bg-white border rounded shadow z-50">
{PUBLIKASI_SUBMENU.map((sub) => (
<Link
key={sub.label}
href={sub.href}
className="block px-4 py-2 text-sm hover:bg-gray-100 text-gray-700 dark:text-white"
>
{sub.label}
</Link>
))}
</div>
)}
</>
) : (
<Link
href={item.href}
onClick={handleClick}
return (
<div key={item.label} className="relative">
{item.label === t("publication") ? (
<>
<button
onClick={() => setDropdownOpen(!isDropdownOpen)}
className={cn(
"relative text-gray-500 dark:text-white dark:hover:text-slate-300 hover:text-black transition-colors",
isActive && "text-black",
"relative text-gray-500 hover:text-black transition-colors",
isDropdownOpen ||
pathname.startsWith("/public/publication")
? "text-black"
: ""
)}
>
{item.label}
{isActive && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-6 h-[3px] bg-red-800 rounded" />
)}
</Link>
)}
</div>
);
})}
</nav>
{/* 🔹 PROFILE / LOGIN SECTION */}
<nav className="hidden md:flex items-center gap-3 z-10 relative">
{!isLoggedIn ? (
<>
<Link href="/auth/register">
<Button className="bg-transparent border text-black hover:bg-red-600 hover:text-white cursor-pointer">
{t("register")}{" "}
</Button>
</Link>
<Link href="/auth">
<Button className="bg-red-700 text-white cursor-pointer hover:bg-white hover:border hover:border-red-700 hover:text-red-700">
{t("login")}
</Button>
</Link>
</>
) : (
<div className="relative">
<button
onClick={() => setShowProfileMenu((prev) => !prev)}
className="flex items-center gap-2 border-2 py-1 px-3 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-700 cursor-pointer"
>
<div className="w-9 h-9 rounded-full overflow-hidden border">
<Image
src="/avatar-profile.png"
alt={username || "User avatar"}
width={36}
height={36}
className="object-cover"
/>
</div>
<span className="text-sm font-medium text-gray-800 dark:text-white">
{fullname}
</span>
<ChevronDown className="w-4 h-4 text-gray-600" />
</button>
{showProfileMenu && (
<div className="absolute right-0 mt-2 w-40 bg-white dark:bg-black border rounded shadow z-50 ">
<Link
href="/admin/dashboard"
className="flex flex-row items-center text-left px-4 py-2 hover:bg-gray-300 dark:hover:bg-gray-700 text-gray-700 dark:text-white"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M14 21a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1zM4 13a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1zm5-2V5H5v6zM4 21a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1zm1-2h4v-2H5zm10 0h4v-6h-4zM13 4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1zm2 1v2h4V5z"
/>
</svg>
&nbsp;Dashboard
</Link>
<button
onClick={handleLogout}
className="w-full flex flex-row text-left px-4 py-2 hover:bg-gray-300 text-gray-700 dark:text-white cursor-pointer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
>
<path
strokeLinejoin="round"
d="M13.477 21.245H8.34a4.92 4.92 0 0 1-5.136-4.623V7.378A4.92 4.92 0 0 1 8.34 2.755h5.136"
/>
<path strokeMiterlimit="10" d="M20.795 12H7.442" />
<path
strokeLinejoin="round"
d="m16.083 17.136l4.404-4.404a1.04 1.04 0 0 0 0-1.464l-4.404-4.404"
/>
</g>
</svg>
&nbsp;{t("logout")}
<span
className={cn(
"absolute -bottom-1 left-1/2 -translate-x-1/2 w-6 h-[3px] bg-red-800 rounded transition-all",
isDropdownOpen ||
pathname.startsWith("/public/publication")
? "opacity-100"
: "opacity-0"
)}
/>
</button>
</div>
{isDropdownOpen && (
<div className="absolute top-full mt-2 w-48 bg-white border rounded shadow z-50">
{PUBLIKASI_SUBMENU.map((sub) => (
<Link
key={sub.label}
href={sub.href}
className="block px-4 py-2 text-sm hover:bg-gray-100 text-gray-700"
>
{sub.label}
</Link>
))}
</div>
)}
</>
) : (
<Link
href={item.href}
onClick={handleClick}
className={cn(
"relative text-gray-500 hover:text-black transition-colors",
isActive && "text-black"
)}
>
{item.label}
{isActive && (
<span className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-6 h-[3px] bg-red-800 rounded" />
)}
</Link>
)}
</div>
)}
</nav>
);
})}
</nav>
{/* 📱 SIDEBAR MOBILE */}
{isSidebarOpen && (
<div className="fixed inset-0 z-50 flex">
<div className="w-80 bg-white p-6 space-y-6 shadow-lg relative h-full overflow-y-auto">
<button
onClick={() => setIsSidebarOpen(false)}
className="absolute top-4 right-4 text-gray-600"
>
</button>
{/* 🔹 PROFILE / LOGIN SECTION */}
<nav className="hidden md:flex items-center gap-3 z-10 relative">
{!isLoggedIn ? (
<>
<Link href="/auth/register">
<Button className="bg-transparent border text-black hover:bg-red-600 hover:text-white">
{t("register")}{" "}
</Button>
</Link>
<Link href="/auth">
<Button className="bg-red-700 text-white">{t("login")}</Button>
</Link>
</>
) : (
<div className="relative">
<button
onClick={() => setShowProfileMenu((prev) => !prev)}
className="flex items-center gap-2 border-2 py-1 px-3 rounded-lg hover:bg-gray-50"
>
<div className="w-9 h-9 rounded-full overflow-hidden border">
<Image
src="/avatar-profile.png"
alt={username || "User avatar"}
width={36}
height={36}
className="object-cover"
/>
</div>
<span className="text-sm font-medium text-gray-800">
{fullname}
</span>
<ChevronDown className="w-4 h-4 text-gray-600" />
</button>
<div className="mt-10">
<h3 className="text-[16px] font-bold text-gray-700 mb-2">
{t("language")}
</h3>
{/* button language */}
<div className={`relative text-left border w-fit rounded-lg`}>
<LocalSwitcher />
</div>
{/* <div className="space-y-5 ml-3">
{showProfileMenu && (
<div className="absolute right-0 mt-2 w-40 bg-white border rounded shadow z-50 ">
<Link
href="/admin/dashboard"
className="flex flex-row items-center text-left px-4 py-2 hover:bg-gray-100 text-gray-700"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M14 21a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1zM4 13a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1zm5-2V5H5v6zM4 21a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1zm1-2h4v-2H5zm10 0h4v-6h-4zM13 4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1zm2 1v2h4V5z"
/>
</svg>
&nbsp;Dashboard
</Link>
<button
onClick={handleLogout}
className="w-full flex flex-row text-left px-4 py-2 hover:bg-gray-100 text-gray-700 cursor-pointer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
>
<path
strokeLinejoin="round"
d="M13.477 21.245H8.34a4.92 4.92 0 0 1-5.136-4.623V7.378A4.92 4.92 0 0 1 8.34 2.755h5.136"
/>
<path strokeMiterlimit="10" d="M20.795 12H7.442" />
<path
strokeLinejoin="round"
d="m16.083 17.136l4.404-4.404a1.04 1.04 0 0 0 0-1.464l-4.404-4.404"
/>
</g>
</svg>
&nbsp;{t("logout")}
</button>
</div>
)}
</div>
)}
</nav>
{/* 📱 SIDEBAR MOBILE */}
{isSidebarOpen && (
<div className="fixed inset-0 z-50 flex">
<div className="w-80 bg-white p-6 space-y-6 shadow-lg relative h-full overflow-y-auto">
<button
onClick={() => setIsSidebarOpen(false)}
className="absolute top-4 right-4 text-gray-600"
>
</button>
<div className="mt-10">
<h3 className="text-[16px] font-bold text-gray-700 mb-2">
{t("language")}
</h3>
{/* button language */}
<div className={`relative text-left border w-fit rounded-lg`}>
<LocalSwitcher />
</div>
{/* <div className="space-y-5 ml-3">
<button className="flex items-center gap-2 text-sm text-gray-800">
<Image src={"/Flag.svg"} width={24} height={24} alt="flag" />
English
@ -311,99 +302,86 @@ export default function Navbar() {
Bahasa Indonesia
</button>
</div> */}
</div>
<div>
<h3 className="text-[16px] font-bold text-gray-700 dark:text-white mb-2">
{t("features")}
</h3>
<div className="space-y-5 ml-3">
{NAV_ITEMS.map((item) => (
<button
key={item.label}
onClick={() => {
if (item.label === t("forYou")) {
if (!checkLoginStatus()) {
router.push("/auth");
} else {
router.push("/for-you");
}
} else {
router.push(item.href);
}
setIsSidebarOpen(false);
}}
className="block text-[15px] text-gray-800 dark:text-white text-left w-full"
>
{item.label}
</button>
))}
</div>
</div>
<div className="space-y-5 text-[16px] font-bold">
<Link
href="/about"
className="block text-black dark:text-white"
>
{t("about")}
</Link>
<Link
href="/advertising"
className="block text-black dark:text-white"
>
{t("advertising")}
</Link>
<Link
href="/contact"
className="block text-black dark:text-white"
>
{t("contact")}
</Link>
{!isLoggedIn ? (
<>
<Link
href="/auth"
className="block text-lg text-gray-800 dark:text-white"
>
{t("login")}
</Link>
<Link
href="/auth/register"
className="block text-lg text-gray-800 dark:text-white"
>
{t("register")}
</Link>
</>
) : (
<button
onClick={handleLogout}
className="block text-left w-full text-lg text-red-600 hover:underline"
>
{t("logout")}
</button>
)}
</div>
<Card className="rounded-none p-4">
<h2 className="text-[#C6A455] text-center text-lg font-semibold mb-2">
{t("subscribeTitle")}
</h2>
<Input type="email" placeholder={t("subscribePlaceholder")} />
<Button className="bg-[#C6A455] mt-2">
{t("subscribeButton")}
</Button>
</Card>
</div>
<div
className="flex-1 bg-black/50"
onClick={() => setIsSidebarOpen(false)}
/>
<div>
<h3 className="text-[16px] font-bold text-gray-700 mb-2">
{t("features")}
</h3>
<div className="space-y-5 ml-3">
{NAV_ITEMS.map((item) => (
<button
key={item.label}
onClick={() => {
if (item.label === t("forYou")) {
if (!checkLoginStatus()) {
router.push("/auth");
} else {
router.push("/for-you");
}
} else {
router.push(item.href);
}
setIsSidebarOpen(false);
}}
className="block text-[15px] text-gray-800 text-left w-full"
>
{item.label}
</button>
))}
</div>
</div>
<div className="space-y-5 text-[16px] font-bold">
<Link href="/about" className="block text-black">
{t("about")}
</Link>
<Link href="/advertising" className="block text-black">
{t("advertising")}
</Link>
<Link href="/contact" className="block text-black">
{t("contact")}
</Link>
{!isLoggedIn ? (
<>
<Link href="/auth" className="block text-lg text-gray-800">
{t("login")}
</Link>
<Link
href="/auth/register"
className="block text-lg text-gray-800"
>
{t("register")}
</Link>
</>
) : (
<button
onClick={handleLogout}
className="block text-left w-full text-lg text-red-600 hover:underline"
>
{t("logout")}
</button>
)}
</div>
<Card className="rounded-none p-4">
<h2 className="text-[#C6A455] text-center text-lg font-semibold mb-2">
{t("subscribeTitle")}
</h2>
<Input type="email" placeholder={t("subscribePlaceholder")} />
<Button className="bg-[#C6A455] mt-2">
{t("subscribeButton")}
</Button>
</Card>
</div>
)}
</header>
</RevealT>
<div
className="flex-1 bg-black/50"
onClick={() => setIsSidebarOpen(false)}
/>
</div>
)}
</header>
);
}

View File

@ -12,7 +12,7 @@ const Loader = () => {
<div className="flex gap-2 items-center ">
{/* <DashCodeLogo className=" text-default-900 h-8 w-8 [&>path:nth-child(3)]:text-background [&>path:nth-child(2)]:text-background" /> */}
<Image
src="/assets/logo1.png"
src="/assets/mediahub-logo-min.png"
alt=""
width={80}
height={80}

View File

@ -93,17 +93,17 @@ export default function ImageDetail({ id }: { id: number }) {
files:
article.files?.map((f: any) => ({
id: f.id,
url: f.fileUrl,
fileName: f.fileName,
filePath: f.filePath,
fileThumbnail: f.fileThumbnail,
fileAlt: f.fileAlt,
widthPixel: f.widthPixel,
heightPixel: f.heightPixel,
url: f.file_url,
fileName: f.file_name,
filePath: f.file_path,
fileThumbnail: f.file_thumbnail,
fileAlt: f.file_alt,
widthPixel: f.width_pixel,
heightPixel: f.height_pixel,
size: f.size,
downloadCount: f.downloadCount,
createdAt: f.createdAt,
updatedAt: f.updatedAt,
downloadCount: f.download_count,
createdAt: f.created_at,
updatedAt: f.updated_at,
})) || [],
};
@ -155,7 +155,7 @@ export default function ImageDetail({ id }: { id: number }) {
<div className="relative">
<Image
placeholder={`data:image/svg+xml;base64,${toBase64(
shimmer(700, 475),
shimmer(700, 475)
)}`}
width={2560}
height={1440}
@ -163,8 +163,8 @@ export default function ImageDetail({ id }: { id: number }) {
data?.files?.[selectedImage]?.url?.trim()
? data.files[selectedImage].url
: data?.thumbnailUrl?.trim()
? data.thumbnailUrl
: "/assets/empty-data.png"
? data.thumbnailUrl
: "/assets/empty-data.png"
}
alt="Main"
className="rounded-lg h-[300px] w-screen lg:h-[600px] lg:w-full object-contain"
@ -185,7 +185,7 @@ export default function ImageDetail({ id }: { id: number }) {
<a onClick={() => setSelectedImage(index)} key={file?.id}>
<Image
placeholder={`data:image/svg+xml;base64,${toBase64(
shimmer(700, 475),
shimmer(700, 475)
)}`}
width={1920}
height={1080}
@ -223,7 +223,7 @@ export default function ImageDetail({ id }: { id: number }) {
</span>
<span className="flex items-center gap-1 border-r-2 pr-2 border-black text-black">
<Eye className="w-4 h-4" />
{data.clickCount || 0}{" "}
{data.viewCount || 0}
</span>
<span className="text-black">
Creator: {data.creatorGroupLevelName}

View File

@ -125,7 +125,7 @@ export default function DashboardContainer() {
return (
<div className="space-y-8">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4 p-2">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
{/* User Profile Card */}
<motion.div
className="col-span-1 md:col-span-2 bg-white rounded-2xl shadow-md border border-gray-200 p-4 hover:shadow-lg transition-all duration-300"
@ -135,11 +135,11 @@ export default function DashboardContainer() {
>
<div className="flex justify-between items-start">
<div className="space-y-2">
<p className="text-gray-600 dark:text-black font-medium">Welcome back,</p>
<p className="text-xl font-semibold text-gray-900 dark:text-black ">{username}</p>
<p className="text-sm text-gray-500 dark:text-black ">Admin Dashboard</p>
<p className="text-gray-600 font-medium">Welcome back,</p>
<p className="text-xl font-semibold text-gray-900">{username}</p>
<p className="text-sm text-gray-500">Admin Dashboard</p>
</div>
<div className="p-3 bg-gradient-to-br from-blue-50 to-purple-50 rounded-xl dark:text-black ">
<div className="p-3 bg-gradient-to-br from-blue-50 to-purple-50 rounded-xl">
<DashboardUserIcon size={60} />
</div>
</div>
@ -153,7 +153,7 @@ export default function DashboardContainer() {
transition={{ delay: 0.2 }}
>
<div className="flex flex-col items-center text-center space-y-3">
<div className="p-3 bg-gradient-to-br from-green-50 to-emerald-50 rounded-xl dark:text-black">
<div className="p-3 bg-gradient-to-br from-green-50 to-emerald-50 rounded-xl">
<DashboardSpeecIcon />
</div>
<div>
@ -173,7 +173,7 @@ export default function DashboardContainer() {
transition={{ delay: 0.3 }}
>
<div className="flex flex-col items-center text-center space-y-3">
<div className="p-3 bg-gradient-to-br from-blue-50 to-cyan-50 rounded-xl dark:text-black">
<div className="p-3 bg-gradient-to-br from-blue-50 to-cyan-50 rounded-xl">
<DashboardConnectIcon />
</div>
<div>
@ -193,7 +193,7 @@ export default function DashboardContainer() {
transition={{ delay: 0.4 }}
>
<div className="flex flex-col items-center text-center space-y-3">
<div className="p-3 bg-gradient-to-br from-purple-50 to-pink-50 rounded-xl dark:text-black">
<div className="p-3 bg-gradient-to-br from-purple-50 to-pink-50 rounded-xl">
<DashboardShareIcon />
</div>
<div>
@ -213,7 +213,7 @@ export default function DashboardContainer() {
transition={{ delay: 0.5 }}
>
<div className="flex flex-col items-center text-center space-y-3">
<div className="p-3 bg-gradient-to-br from-orange-50 to-red-50 rounded-xl dark:text-black">
<div className="p-3 bg-gradient-to-br from-orange-50 to-red-50 rounded-xl">
<DashboardCommentIcon size={40} />
</div>
<div>
@ -227,7 +227,7 @@ export default function DashboardContainer() {
</div>
{/* Content Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-2">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Analytics Chart */}
<motion.div
className="bg-white rounded-2xl shadow-md border border-gray-200 p-4"
@ -268,7 +268,7 @@ export default function DashboardContainer() {
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.7 }}
>
{/* <div className="flex justify-between items-center mb-6">
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg font-semibold text-gray-900">
Recent Content
</h3>
@ -277,7 +277,7 @@ export default function DashboardContainer() {
Create Content
</Button>
</Link>
</div> */}
</div>
<div className="space-y-4 max-h-96 overflow-y-auto scrollbar-thin">
{article?.map((list: any) => (
@ -306,12 +306,12 @@ export default function DashboardContainer() {
))}
</div>
{/* <div className="mt-6 flex justify-center">
<div className="mt-6 flex justify-center">
<CustomPagination
totalPage={totalPage}
onPageChange={(data) => setPage(data)}
/>
</div> */}
</div>
</motion.div>
</div>
</div>

View File

@ -113,12 +113,12 @@ export function WorkflowModalProvider({ children }: WorkflowModalProviderProps)
return (
<WorkflowModalContext.Provider value={{ showWorkflowModal, hideWorkflowModal, refreshWorkflowStatus }}>
{children}
<WorkflowSetupModal
{/* <WorkflowSetupModal
isOpen={isModalOpen}
onClose={hideWorkflowModal}
workflowInfo={workflowInfo}
onRefresh={refreshWorkflowStatus}
/>
/> */}
</WorkflowModalContext.Provider>
);
}

View File

@ -1,11 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { IconX, SettingsIcon } from "@/components/icons";
@ -25,12 +20,7 @@ interface WorkflowSetupModalProps {
onRefresh?: () => Promise<void>;
}
export default function WorkflowSetupModal({
isOpen,
onClose,
workflowInfo,
onRefresh,
}: WorkflowSetupModalProps) {
export default function WorkflowSetupModal({ isOpen, onClose, workflowInfo, onRefresh }: WorkflowSetupModalProps) {
const router = useRouter();
const pathname = usePathname();
const [isVisible, setIsVisible] = useState(false);
@ -43,11 +33,7 @@ export default function WorkflowSetupModal({
const handleClose = () => {
// Allow closing if workflow is setup OR if user is on tenant settings page
if (
workflowInfo?.hasWorkflowSetup ||
pathname?.includes("/admin/settings/tenant") ||
pathname?.includes("/tenant")
) {
if (workflowInfo?.hasWorkflowSetup || pathname?.includes('/admin/settings/tenant') || pathname?.includes('/tenant')) {
setIsVisible(false);
setTimeout(() => {
onClose();
@ -64,35 +50,21 @@ export default function WorkflowSetupModal({
if (!isOpen) return null;
return (
<Dialog
open={isVisible}
onOpenChange={
workflowInfo?.hasWorkflowSetup ||
pathname?.includes("/admin/settings/tenant") ||
pathname?.includes("/tenant")
? handleClose
: undefined
}
<Dialog
open={isVisible}
onOpenChange={(workflowInfo?.hasWorkflowSetup || pathname?.includes('/admin/settings/tenant') || pathname?.includes('/tenant')) ? handleClose : undefined}
>
<DialogContent
<DialogContent
className="max-w-md"
onPointerDownOutside={(e) => {
// Prevent closing by clicking outside unless workflow is setup or on tenant settings page
if (
!workflowInfo?.hasWorkflowSetup &&
!pathname?.includes("/admin/settings/tenant") &&
!pathname?.includes("/tenant")
) {
if (!workflowInfo?.hasWorkflowSetup && !pathname?.includes('/admin/settings/tenant') && !pathname?.includes('/tenant')) {
e.preventDefault();
}
}}
onEscapeKeyDown={(e) => {
// Prevent closing by pressing ESC unless workflow is setup or on tenant settings page
if (
!workflowInfo?.hasWorkflowSetup &&
!pathname?.includes("/admin/settings/tenant") &&
!pathname?.includes("/tenant")
) {
if (!workflowInfo?.hasWorkflowSetup && !pathname?.includes('/admin/settings/tenant') && !pathname?.includes('/tenant')) {
e.preventDefault();
}
}}
@ -113,139 +85,46 @@ export default function WorkflowSetupModal({
</DialogHeader>
<div className="space-y-4">
{
!workflowInfo?.hasWorkflowSetup ? (
// No Workflow Setup
<Card className="border-orange-200 bg-orange-50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="h-6 w-6 rounded-full bg-orange-600 flex items-center justify-center mt-1">
<span className="text-white text-sm">!</span>
</div>
<div className="flex-1">
<h3 className="font-medium text-orange-900 mb-2">
Workflow Belum Dikonfigurasi
</h3>
<p className="text-sm text-orange-700 mb-4">
Anda belum melakukan setup workflow, silahkan setup
terlebih dahulu.
</p>
<div className="flex gap-2">
<Button
onClick={handleSetupWorkflow}
className="bg-orange-600 hover:bg-orange-700 text-white"
size="sm"
>
<SettingsIcon className="h-4 w-4 mr-2" />
Setup Workflow
</Button>
{(pathname?.includes("/admin/settings/tenant") ||
pathname?.includes("/tenant")) && (
<Button
variant="outline"
onClick={handleClose}
size="sm"
>
Cancel
</Button>
)}
</div>
</div>
{!workflowInfo?.hasWorkflowSetup ? (
// No Workflow Setup
<Card className="border-orange-200 bg-orange-50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="h-6 w-6 rounded-full bg-orange-600 flex items-center justify-center mt-1">
<span className="text-white text-sm">!</span>
</div>
</CardContent>
</Card>
) : (
<Card className="border-green-200 bg-green-50">
<CardContent className="p-5 space-y-4">
<div className="flex items-start gap-3">
<div className="h-6 w-6 rounded-full bg-green-600 flex items-center justify-center mt-1">
<span className="text-white text-sm"></span>
</div>
<div className="flex-1">
<h3 className="font-semibold text-green-900 mb-3">
Workflow Sudah Dikonfigurasi
</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">
Default Workflow
</span>
<span className="font-medium">
{workflowInfo.defaultWorkflowName}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Workflow ID</span>
<span className="font-mono">
#{workflowInfo.defaultWorkflowId}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">
Requires Approval
</span>
<span
className={
workflowInfo.requiresApproval
? "text-green-600 font-medium"
: "text-gray-500"
}
>
{workflowInfo.requiresApproval ? "Yes" : "No"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Auto Publish</span>
<span
className={
workflowInfo.autoPublishArticles
? "text-green-600 font-medium"
: "text-gray-500"
}
>
{workflowInfo.autoPublishArticles ? "Yes" : "No"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Approval Status</span>
<span
className={
workflowInfo.isApprovalActive
? "text-green-600 font-medium"
: "text-red-600 font-medium"
}
>
{workflowInfo.isApprovalActive
? "Active"
: "Inactive"}
</span>
</div>
</div>
{/* <div className="flex gap-2 mt-4">
<Button
<div className="flex-1">
<h3 className="font-medium text-orange-900 mb-2">
Workflow Belum Dikonfigurasi
</h3>
<p className="text-sm text-orange-700 mb-4">
Anda belum melakukan setup workflow, silahkan setup terlebih dahulu.
</p>
<div className="flex gap-2">
<Button
onClick={handleSetupWorkflow}
variant="outline"
className="bg-orange-600 hover:bg-orange-700 text-white"
size="sm"
>
<SettingsIcon className="h-4 w-4 mr-2" />
Manage Workflow
Setup Workflow
</Button>
<Button variant="outline" onClick={handleClose} size="sm">
Close
</Button>
</div> */}
{(pathname?.includes('/admin/settings/tenant') || pathname?.includes('/tenant')) && (
<Button
variant="outline"
onClick={handleClose}
size="sm"
>
Cancel
</Button>
)}
</div>
</div>
</CardContent>
</Card>
// Workflow Setup Complete
</div>
</CardContent>
</Card>
) : ''
// Workflow Setup Complete
// <Card className="border-green-200 bg-green-50">
// <CardContent className="p-4">
// <div className="flex items-start gap-3">
@ -281,7 +160,7 @@ export default function WorkflowSetupModal({
// </div>
// </div>
// <div className="flex gap-2 mt-4">
// <Button
// <Button
// onClick={handleSetupWorkflow}
// variant="outline"
// size="sm"
@ -294,8 +173,6 @@ export default function WorkflowSetupModal({
// </div>
// </CardContent>
// </Card>
)
}
</div>
</DialogContent>

View File

@ -58,13 +58,13 @@ const columns: ColumnDef<any>[] = [
<span className="normal-case">{row.getValue("userLevelGroup") || "-"}</span>
),
},
// {
// accessorKey: "statusId",
// header: "Status ID",
// cell: ({ row }) => (
// <span>{row.getValue("statusId")}</span>
// ),
// },
{
accessorKey: "statusId",
header: "Status ID",
cell: ({ row }) => (
<span>{row.getValue("statusId")}</span>
),
},
{
accessorKey: "createdAt",
header: "Tanggal Unggah",
@ -143,14 +143,14 @@ const columns: ColumnDef<any>[] = [
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="p-0" align="end">
<DropdownMenuItem className="p-2 border-b text-default-700 group focus:bg-slate-200 focus:text-blue-500 rounded-none">
<DropdownMenuItem className="p-2 border-b text-default-700 group focus:bg-default focus:text-primary-foreground rounded-none">
<Link
href={`/admin/management-user/detail/${row.original.id}`}
>
Detail
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="p-2 border-b text-default-700 group focus:bg-slate-200 focus:text-blue-500 rounded-none">
<DropdownMenuItem className="p-2 border-b text-default-700 group focus:bg-default focus:text-primary-foreground rounded-none">
<Link
href={`/admin/management-user/edit/${row.original.id}`}
>
@ -159,7 +159,7 @@ const columns: ColumnDef<any>[] = [
</DropdownMenuItem>
<DropdownMenuItem
color="red"
className="p-2 border-b text-red-500 group focus:bg-red-500 focus:text-white rounded-none cursor-pointer"
className="p-2 border-b text-red-500 group focus:bg-red-500 focus:text-white rounded-none"
>
<a
onClick={() => {

Some files were not shown because too many files have changed in this diff Show More