feat: category table and create
This commit is contained in:
parent
50b1b1fed9
commit
e6077d6182
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
|
||||||
|
const ImageCreatePage = async () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* <SiteBreadcrumb /> */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* <CategoriesForm /> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageCreatePage;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue