feat: category table and create

This commit is contained in:
Sabda Yagra 2025-09-23 23:49:33 +07:00
parent 50b1b1fed9
commit e6077d6182
7 changed files with 909 additions and 7 deletions

View File

@ -0,0 +1,186 @@
"use client";
import * as React from "react";
import { ColumnDef } from "@tanstack/react-table";
import { Eye, MoreVertical, SquarePen, Trash2 } from "lucide-react";
import { cn, getCookiesDecrypt } 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 { format } from "date-fns";
import { useRouter } from "next/navigation";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import Link from "next/link";
import { error } from "@/lib/swal";
import { deleteCategories } from "@/service/categories/categories";
const useCategoryColumns = () => {
const MySwal = withReactContent(Swal);
const columns: ColumnDef<any>[] = [
{
accessorKey: "no",
header: "No",
cell: ({ row }) => <span>{row.getValue("no")}</span>,
},
{
accessorKey: "title",
header: "Title",
cell: ({ row }) => {
const title: string = row.getValue("title");
return (
<span className="whitespace-nowrap">
{title?.length > 50 ? `${title.slice(0, 30)}...` : title}
</span>
);
},
},
{
accessorKey: "description",
header: "Description",
cell: ({ row }) => {
const desc: string = row.getValue("description");
return (
<span className="text-sm text-muted-foreground">
{desc?.length > 80 ? `${desc.slice(0, 50)}...` : desc || "-"}
</span>
);
},
},
{
accessorKey: "createdAt",
header: "Created At",
cell: ({ row }) => {
const createdAt = row.getValue("createdAt") as string | number;
return createdAt ? (
<span className="whitespace-nowrap">
{format(new Date(createdAt), "dd-MM-yyyy HH:mm")}
</span>
) : (
"-"
);
},
},
{
accessorKey: "createdByName",
header: "Created By",
cell: ({ row }) => <span>{row.getValue("createdByName") || "-"}</span>,
},
{
accessorKey: "isPublish",
header: "Publish",
cell: ({ row }) => {
const isPublish = row.getValue("isPublish");
return (
<Badge
className={cn(
"rounded-full px-3",
isPublish
? "bg-green-100 text-green-600"
: "bg-red-100 text-red-600"
)}
>
{isPublish ? "Published" : "Draft"}
</Badge>
);
},
},
// {
// accessorKey: "statusName",
// header: "Status",
// cell: ({ row }) => {
// const statusName = row.getValue("statusName");
// return (
// <Badge
// className={cn(
// "rounded-full px-3",
// statusName === "Active"
// ? "bg-green-100 text-green-600"
// : "bg-gray-200 text-gray-600"
// )}
// >
// {statusName || "-"}
// </Badge>
// );
// },
// },
{
id: "actions",
header: "Action",
cell: ({ row }) => {
const router = useRouter();
const MySwal = withReactContent(Swal);
async function doDelete(id: number) {
const response = await deleteCategories(id);
if (response?.error) {
error(response.message);
return;
}
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonText: "OK",
}).then(() => {
window.location.reload();
});
}
const handleDelete = (id: number) => {
MySwal.fire({
title: "Hapus Category?",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#d33",
confirmButtonText: "Hapus",
}).then((result) => {
if (result.isConfirmed) doDelete(id);
});
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
href={`/admin/categories/detail/${row.original.id}`}
className="hover:text-black"
>
<DropdownMenuItem>
<Eye className="w-4 h-4 mr-2" /> View
</DropdownMenuItem>
</Link>
<Link
href={`/admin/categories/update/${row.original.id}`}
className="hover:text-black"
>
<DropdownMenuItem>
<SquarePen className="w-4 h-4 mr-2" /> Edit
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => handleDelete(row.original.id)}
className="text-destructive focus:bg-destructive focus:text-white"
>
<Trash2 className="w-4 h-4 mr-2" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
return columns;
};
export default useCategoryColumns;

View File

@ -0,0 +1,274 @@
"use client";
import * as React from "react";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
ChevronDown,
Eye,
MoreVertical,
SquarePen,
Trash2,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import TablePagination from "@/components/table/table-pagination";
import {
deleteCategories,
getCategories,
} from "@/service/categories/categories";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import Link from "next/link";
import { error } from "@/lib/swal";
const TableCategories = () => {
const [dataTable, setDataTable] = React.useState<any[]>([]);
const [totalData, setTotalData] = React.useState<number>(1);
const [totalPage, setTotalPage] = React.useState(1);
const [page, setPage] = React.useState(1);
const [showData, setShowData] = React.useState("10");
const MySwal = withReactContent(Swal);
// ✅ definisi kolom untuk table categories
const columns: ColumnDef<any>[] = [
{
accessorKey: "no",
header: "No",
cell: ({ row }) => row.original.no,
},
{
accessorKey: "title",
header: "Nama Kategori",
cell: ({ row }) => row.original.title || "-",
},
{
accessorKey: "description",
header: "Deskripsi",
cell: ({ row }) => row.original.description || "-",
},
{
accessorKey: "statusId",
header: "Status",
cell: ({ row }) => (row.original.isPublish ? "Publish" : "Draft"),
},
{
id: "actions",
header: "Action",
enableHiding: false,
cell: ({ row }) => {
const doDelete = async (id: number) => {
const response = await deleteCategories(id);
if (response?.error) {
error(response.message);
return;
}
MySwal.fire({
title: "Sukses",
icon: "success",
confirmButtonText: "OK",
}).then(() => {
window.location.reload();
});
};
const handleDelete = (id: number) => {
MySwal.fire({
title: "Hapus Kategori?",
icon: "warning",
showCancelButton: true,
cancelButtonColor: "#3085d6",
confirmButtonColor: "#d33",
confirmButtonText: "Hapus",
}).then((result) => {
if (result.isConfirmed) {
doDelete(id);
}
});
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
className="bg-transparent hover:bg-transparent"
>
<span className="sr-only">Open menu</span>
<MoreVertical className="h-4 w-4 text-default-800" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="p-0" align="end">
<Link href={`/admin/categories/detail/${row.original.id}`}>
<DropdownMenuItem className="p-2 border-b text-default-700 rounded-none">
<Eye className="w-4 h-4 mr-1.5" /> View
</DropdownMenuItem>
</Link>
<Link href={`/admin/categories/update/${row.original.id}`}>
<DropdownMenuItem className="p-2 border-b text-default-700 rounded-none">
<SquarePen className="w-4 h-4 mr-1.5" /> Edit
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => handleDelete(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 mr-1.5" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
// setup table
const table = useReactTable({
data: dataTable,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
// fetch categories data
React.useEffect(() => {
fetchData();
}, [page, showData]);
async function fetchData() {
try {
const res = await getCategories({
page,
limit: Number(showData),
sort: "desc",
sortBy: "id",
});
const data = res?.data?.data?.content || res?.data?.data || [];
const count = res?.data?.data?.totalElements || data.length;
const totalPages =
res?.data?.data?.totalPages || Math.ceil(count / Number(showData));
// tambahkan nomor urut
const withIndex = data.map((item: any, index: number) => ({
...item,
no: (page - 1) * Number(showData) + index + 1,
}));
setDataTable(withIndex);
setTotalData(count);
setTotalPage(totalPages);
} catch (err) {
console.error("Error fetch categories:", err);
setDataTable([]);
}
}
return (
<>
<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>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto" size="sm">
Columns <ChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<Table>
<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>
{dataTable.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>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No categories found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<TablePagination
table={table}
totalData={totalData}
totalPage={totalPage}
/>
</div>
</>
);
};
export default TableCategories;

View File

@ -0,0 +1,13 @@
const ImageCreatePage = async () => {
return (
<div>
{/* <SiteBreadcrumb /> */}
<div className="space-y-4">
{/* <CategoriesForm /> */}
</div>
</div>
);
};
export default ImageCreatePage;

View File

@ -0,0 +1,11 @@
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Media Hub | POLRI",
description: "Media Hub merupakan situs resmi milik Divisi Humas Polri di mana di dalamnya berisi konten-konten yang dapat diakses secara gratis oleh Internal Polri, Jurnalis, Masyarakat Umum, dan KSP.",
};
const Layout = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
export default Layout;

View File

@ -0,0 +1,315 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import SiteBreadcrumb from "@/components/site-breadcrumb";
import { UploadIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import TableCategories from "./components/table-categories";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { loading, close, error } from "@/config/swal";
import { useState } from "react";
import withReactContent from "sweetalert2-react-content";
import Swal from "sweetalert2";
import { createCategories } from "@/service/categories/categories";
const FormSchema = z.object({
createdById: z.coerce.number(),
description: z.string().min(1, "Deskripsi wajib diisi"),
oldCategoryId: z.coerce.number(),
parentId: z.coerce.number(),
slug: z.string().min(1, "Slug wajib diisi"),
statusId: z.coerce.number(),
tags: z.string().min(1, "Tags wajib diisi"),
title: z.string().min(1, "Nama kategori wajib diisi"),
});
// helper untuk bikin slug otomatis
const slugify = (text: string) =>
text
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-");
const ReactTableImagePage = () => {
const [isOpen, setIsOpen] = useState(false);
const MySwal = withReactContent(Swal);
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
createdById: 1,
description: "",
oldCategoryId: 0,
parentId: 0,
slug: "",
statusId: 1,
tags: "",
title: "",
},
});
// Auto generate slug ketika title berubah
const handleTitleChange = (value: string) => {
form.setValue("title", value);
const autoSlug = slugify(value);
if (
!form.getValues("slug") ||
form.getValues("slug") ===
slugify(form.formState.defaultValues?.title || "")
) {
form.setValue("slug", autoSlug);
}
};
const onSubmit = async (data: z.infer<typeof FormSchema>) => {
loading();
try {
const response = await createCategories(data);
// ✅ Tutup swal "Loading" sebelum tampil swal baru
MySwal.close();
if (response?.error) {
const message =
typeof response.message === "string"
? response.message
: Array.isArray(response.message)
? response.message.join(", ")
: JSON.stringify(response.message);
await MySwal.fire({
icon: "error",
title: "Gagal",
text: message || "Gagal membuat kategori",
confirmButtonColor: "#d33",
timer: 2000, // ⏱ otomatis hilang setelah 2 detik
showConfirmButton: false,
});
return;
}
await MySwal.fire({
icon: "success",
title: "Sukses",
text: "Kategori berhasil dibuat",
confirmButtonColor: "#3085d6",
timer: 2000, // ⏱ otomatis hilang setelah 2 detik
showConfirmButton: false,
});
setIsOpen(false);
} catch (err: any) {
MySwal.close();
await MySwal.fire({
icon: "error",
title: "Terjadi kesalahan",
text: err?.message || "Coba lagi nanti",
confirmButtonColor: "#d33",
timer: 2000, // ⏱ otomatis hilang setelah 2 detik
showConfirmButton: false,
});
}
};
return (
<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 rounded-t-lg">
<CardTitle>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<UploadIcon className="w-4 h-4 text-blue-600" />
</div>
<div>
<h1 className="text-xl font-semibold text-gray-900">
Categories Management
</h1>
<p className="text-sm text-gray-500">
Manage your categories
</p>
</div>
</div>
<div className="flex-none">
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button color="primary">Tambah Kategori</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Buat Kategori Baru</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
{/* Title */}
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Nama Kategori</FormLabel>
<FormControl>
<Input
placeholder="Masukkan nama kategori"
{...field}
onChange={(e) =>
handleTitleChange(e.target.value)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Slug */}
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input
placeholder="contoh: berita-politik"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Deskripsi */}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Deskripsi</FormLabel>
<FormControl>
<Textarea
placeholder="Masukkan deskripsi kategori"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Tags */}
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<Input
placeholder="contoh: berita, politik"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* createdById, oldCategoryId, parentId, statusId → bisa hidden atau number input */}
<FormField
control={form.control}
name="createdById"
render={({ field }) => (
<FormItem>
<FormLabel>Created By ID</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="statusId"
render={({ field }) => (
<FormItem>
<FormLabel>Status ID</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" color="primary">
Simpan
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
{/* <Link href={"/admin/categories/create"}>
<Button
color="primary"
className="text-white shadow-sm hover:shadow-md transition-shadow"
>
<UploadIcon size={18} className="mr-2" />
Create Categories
</Button>
</Link> */}
{/* <Link href={"/contributor/content/image/createAi"}>
<Button color="primary" className="text-white ml-3">
<UploadIcon />
Unggah Foto Dengan AI
</Button>
</Link> */}
</div>
</div>
</CardTitle>
</CardHeader>
<CardContent className="p-6 bg-gray-50">
<TableCategories />
</CardContent>
</Card>
</div>
</div>
</div>
);
};
export default ReactTableImagePage;

View File

@ -55,10 +55,10 @@ const sidebarSections: SidebarSection[] = [
], ],
}, },
{ {
title: "Content", title: "Konten",
items: [ items: [
{ {
title: "Master Data", title: "Konten",
icon: () => <Icon icon="mdi:folder-outline" className="text-lg" />, icon: () => <Icon icon="mdi:folder-outline" className="text-lg" />,
children: [ children: [
{ {
@ -90,6 +90,17 @@ const sidebarSections: SidebarSection[] = [
}, },
], ],
}, },
{
title: "Master Data",
icon: () => <Icon icon="mdi:folder-outline" className="text-lg" />,
children: [
{
title: "Kategori Konten",
icon: () => <Icon icon="ri:article-line" className="text-lg" />,
link: "/admin/categories",
},
],
},
], ],
}, },
]; ];
@ -207,10 +218,12 @@ const SidebarContent = ({
updateSidebarData: (newData: boolean) => void; updateSidebarData: (newData: boolean) => void;
}) => { }) => {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const [expanded, setExpanded] = useState<string | null>(null); // track parent yang dibuka const [expanded, setExpanded] = useState<string[]>([]);
const toggleExpand = (title: string) => { const toggleExpand = (title: string) => {
setExpanded((prev) => (prev === title ? null : title)); setExpanded((prev) =>
prev.includes(title) ? prev.filter((t) => t !== title) : [...prev, title]
);
}; };
const handleLogout = () => { const handleLogout = () => {
@ -306,7 +319,7 @@ const SidebarContent = ({
{open && ( {open && (
<motion.span <motion.span
animate={{ animate={{
rotate: expanded === item.title ? 90 : 0, rotate: expanded.includes(item.title) ? 90 : 0,
}} }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="text-slate-500" className="text-slate-500"
@ -320,8 +333,8 @@ const SidebarContent = ({
<motion.div <motion.div
initial={false} initial={false}
animate={{ animate={{
height: expanded === item.title ? "auto" : 0, height: expanded.includes(item.title) ? "auto" : 0,
opacity: expanded === item.title ? 1 : 0, opacity: expanded.includes(item.title) ? 1 : 0,
}} }}
className="overflow-hidden ml-6 space-y-1" className="overflow-hidden ml-6 space-y-1"
> >

View File

@ -0,0 +1,90 @@
import { getCookiesDecrypt } from "@/lib/utils";
import { httpPost } from "../http-config/http-base-service";
import {
httpDeleteInterceptor,
httpGetInterceptor,
httpPostInterceptor,
} from "../http-config/http-interceptor-service";
interface GetCategoriesParams {
UserLevelId?: number;
UserLevelNumber?: number;
description?: string;
isPublish?: boolean;
parentId?: number;
statusId?: number;
title?: string;
count?: number;
limit?: number;
nextPage?: number;
page?: number;
previousPage?: number;
sort?: string;
sortBy?: string;
totalPage?: number;
region?: string; // kalau ada publishedLocation
}
interface CreateCategoryPayload {
createdById: number;
description: string;
oldCategoryId: number;
parentId: number;
slug: string;
statusId: number;
title: string;
tags: string;
}
export async function getCategories(params: GetCategoriesParams) {
const query = new URLSearchParams();
if (params.UserLevelId)
query.append("UserLevelId", params.UserLevelId.toString());
if (params.UserLevelNumber)
query.append("UserLevelNumber", params.UserLevelNumber.toString());
if (params.description) query.append("description", params.description);
if (params.isPublish !== undefined)
query.append("isPublish", String(params.isPublish));
if (params.parentId) query.append("parentId", params.parentId.toString());
if (params.statusId) query.append("statusId", params.statusId.toString());
if (params.title) query.append("title", params.title);
if (params.count) query.append("count", params.count.toString());
if (params.limit) query.append("limit", params.limit.toString());
if (params.nextPage) query.append("nextPage", params.nextPage.toString());
if (params.page) query.append("page", params.page.toString());
if (params.previousPage)
query.append("previousPage", params.previousPage.toString());
if (params.sort) query.append("sort", params.sort);
if (params.sortBy) query.append("sortBy", params.sortBy);
if (params.totalPage) query.append("totalPage", params.totalPage.toString());
if (params.region) query.append("publishedLocation", params.region);
const url = `/article-categories?${query.toString()}`;
return httpGetInterceptor(url);
}
export async function deleteCategories(id: number) {
const clientKey = getCookiesDecrypt("clientKey");
const csrfToken = getCookiesDecrypt("csrfToken");
const url = `/article-categories/${id}`;
return httpDeleteInterceptor(url, {
headers: {
"X-Client-Key": clientKey,
"X-Csrf-Token": csrfToken,
},
});
}
export async function createCategories(data: CreateCategoryPayload) {
const url = "/article-categories";
const headers = {
"Content-Type": "application/json",
"X-Client-Key": process.env.NEXT_PUBLIC_CLIENT_KEY || "",
};
return httpPostInterceptor(url, data, headers);
}