Add admin category management
This commit is contained in:
512
src/app/admin/categories/page.tsx
Normal file
512
src/app/admin/categories/page.tsx
Normal file
@ -0,0 +1,512 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface CategoryRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubCategoryRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
subCategoryAttributes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken() {
|
||||||
|
if (typeof window === "undefined") return "";
|
||||||
|
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toText(value: unknown) {
|
||||||
|
return typeof value === "string" ? value : typeof value === "number" ? String(value) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCategories(payload: unknown): CategoryRow[] {
|
||||||
|
const rows =
|
||||||
|
Array.isArray((payload as { rows?: unknown[] })?.rows) ? (payload as { rows: unknown[] }).rows :
|
||||||
|
Array.isArray((payload as { data?: unknown[] })?.data) ? (payload as { data: unknown[] }).data :
|
||||||
|
Array.isArray(payload) ? payload :
|
||||||
|
[];
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((item) => {
|
||||||
|
const row = item as Record<string, unknown>;
|
||||||
|
const id = toText(row.id);
|
||||||
|
const name = toText(row.name);
|
||||||
|
if (!id || !name) return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description: toText(row.description) || null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is CategoryRow => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSubCategories(payload: unknown): SubCategoryRow[] {
|
||||||
|
const rows =
|
||||||
|
Array.isArray((payload as { rows?: unknown[] })?.rows) ? (payload as { rows: unknown[] }).rows :
|
||||||
|
Array.isArray((payload as { data?: unknown[] })?.data) ? (payload as { data: unknown[] }).data :
|
||||||
|
Array.isArray(payload) ? payload :
|
||||||
|
[];
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((item) => {
|
||||||
|
const row = item as Record<string, unknown>;
|
||||||
|
const id = toText(row.id);
|
||||||
|
const name = toText(row.name);
|
||||||
|
if (!id || !name) return null;
|
||||||
|
|
||||||
|
const attributes = Array.isArray(row.subCategoryAttributes)
|
||||||
|
? row.subCategoryAttributes.map((attr) => toText(attr)).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description: toText(row.description) || null,
|
||||||
|
subCategoryAttributes: attributes,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is SubCategoryRow => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminCategoriesPage() {
|
||||||
|
const [categories, setCategories] = useState<CategoryRow[]>([]);
|
||||||
|
const [selectedCategoryId, setSelectedCategoryId] = useState("");
|
||||||
|
const [subcategories, setSubcategories] = useState<SubCategoryRow[]>([]);
|
||||||
|
const [loadingCategories, setLoadingCategories] = useState(true);
|
||||||
|
const [loadingSubcategories, setLoadingSubcategories] = useState(false);
|
||||||
|
const [categoryError, setCategoryError] = useState("");
|
||||||
|
const [subcategoryError, setSubcategoryError] = useState("");
|
||||||
|
const [categoryName, setCategoryName] = useState("");
|
||||||
|
const [categoryDescription, setCategoryDescription] = useState("");
|
||||||
|
const [subCategoryName, setSubCategoryName] = useState("");
|
||||||
|
const [subCategoryDescription, setSubCategoryDescription] = useState("");
|
||||||
|
const [attributeInput, setAttributeInput] = useState("");
|
||||||
|
const [subCategoryAttributes, setSubCategoryAttributes] = useState<string[]>([]);
|
||||||
|
const [savingCategory, setSavingCategory] = useState(false);
|
||||||
|
const [savingSubcategory, setSavingSubcategory] = useState(false);
|
||||||
|
|
||||||
|
const selectedCategory =
|
||||||
|
categories.find((category) => category.id === selectedCategoryId) || null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadCategories() {
|
||||||
|
setLoadingCategories(true);
|
||||||
|
setCategoryError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/categories?page=0&size=100", {
|
||||||
|
headers: { "x-auth-token": getToken() },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data?.responseDesc || data?.error || "Gagal memuat category");
|
||||||
|
}
|
||||||
|
const rows = parseCategories(data);
|
||||||
|
setCategories(rows);
|
||||||
|
setSelectedCategoryId((current) => current || rows[0]?.id || "");
|
||||||
|
} catch (error) {
|
||||||
|
setCategoryError(error instanceof Error ? error.message : "Gagal memuat category");
|
||||||
|
} finally {
|
||||||
|
setLoadingCategories(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadSubcategories() {
|
||||||
|
if (!selectedCategoryId) {
|
||||||
|
setSubcategories([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingSubcategories(true);
|
||||||
|
setSubcategoryError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/categories/${selectedCategoryId}/subcategories?page=0&size=100`, {
|
||||||
|
headers: { "x-auth-token": getToken() },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data?.responseDesc || data?.error || "Gagal memuat sub-category");
|
||||||
|
}
|
||||||
|
setSubcategories(parseSubCategories(data));
|
||||||
|
} catch (error) {
|
||||||
|
setSubcategoryError(error instanceof Error ? error.message : "Gagal memuat sub-category");
|
||||||
|
} finally {
|
||||||
|
setLoadingSubcategories(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSubcategories();
|
||||||
|
}, [selectedCategoryId]);
|
||||||
|
|
||||||
|
function addAttribute() {
|
||||||
|
const normalized = attributeInput.trim();
|
||||||
|
if (!normalized) return;
|
||||||
|
setSubCategoryAttributes((current) =>
|
||||||
|
current.includes(normalized) ? current : [...current, normalized]
|
||||||
|
);
|
||||||
|
setAttributeInput("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateCategory() {
|
||||||
|
if (!categoryName.trim()) return;
|
||||||
|
|
||||||
|
setSavingCategory(true);
|
||||||
|
setCategoryError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/categories", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-auth-token": getToken(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: categoryName.trim(),
|
||||||
|
description: categoryDescription.trim() || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data?.responseDesc || data?.error || "Gagal menambah category");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRows = parseCategories(data);
|
||||||
|
if (newRows[0]) {
|
||||||
|
setCategories((current) => [newRows[0], ...current]);
|
||||||
|
setSelectedCategoryId(newRows[0].id);
|
||||||
|
} else {
|
||||||
|
const refreshRes = await fetch("/api/admin/categories?page=0&size=100", {
|
||||||
|
headers: { "x-auth-token": getToken() },
|
||||||
|
});
|
||||||
|
const refreshData = await refreshRes.json();
|
||||||
|
const rows = parseCategories(refreshData);
|
||||||
|
setCategories(rows);
|
||||||
|
setSelectedCategoryId(rows[0]?.id || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
setCategoryName("");
|
||||||
|
setCategoryDescription("");
|
||||||
|
} catch (error) {
|
||||||
|
setCategoryError(error instanceof Error ? error.message : "Gagal menambah category");
|
||||||
|
} finally {
|
||||||
|
setSavingCategory(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateSubcategory() {
|
||||||
|
if (!selectedCategoryId || !subCategoryName.trim()) return;
|
||||||
|
|
||||||
|
setSavingSubcategory(true);
|
||||||
|
setSubcategoryError("");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/categories/${selectedCategoryId}/subcategories`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-auth-token": getToken(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: subCategoryName.trim(),
|
||||||
|
description: subCategoryDescription.trim() || null,
|
||||||
|
subCategoryAttributes,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data?.responseDesc || data?.error || "Gagal menambah sub-category");
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshRes = await fetch(`/api/admin/categories/${selectedCategoryId}/subcategories?page=0&size=100`, {
|
||||||
|
headers: { "x-auth-token": getToken() },
|
||||||
|
});
|
||||||
|
const refreshData = await refreshRes.json();
|
||||||
|
setSubcategories(parseSubCategories(refreshData));
|
||||||
|
|
||||||
|
setSubCategoryName("");
|
||||||
|
setSubCategoryDescription("");
|
||||||
|
setSubCategoryAttributes([]);
|
||||||
|
setAttributeInput("");
|
||||||
|
} catch (error) {
|
||||||
|
setSubcategoryError(error instanceof Error ? error.message : "Gagal menambah sub-category");
|
||||||
|
} finally {
|
||||||
|
setSavingSubcategory(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="mb-10 flex items-end justify-between gap-6">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<p className="mb-3 text-[10px] font-black uppercase tracking-[0.25em] text-primary">
|
||||||
|
Taxonomy Console
|
||||||
|
</p>
|
||||||
|
<h2 className="text-4xl font-extrabold tracking-tight text-on-surface">
|
||||||
|
Category Management
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-base text-slate-500">
|
||||||
|
Kelola category dan sub-category produk untuk admin panel menggunakan endpoint category dari backend.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="rounded-xl bg-surface-container-lowest p-5 shadow-[0px_20px_40px_rgba(25,28,30,0.06)]">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.15em] text-slate-400">Categories</p>
|
||||||
|
<p className="mt-2 text-3xl font-black text-on-surface">{categories.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-surface-container-lowest p-5 shadow-[0px_20px_40px_rgba(25,28,30,0.06)]">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.15em] text-slate-400">Sub-Categories</p>
|
||||||
|
<p className="mt-2 text-3xl font-black text-on-surface">{subcategories.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-8 xl:grid-cols-12">
|
||||||
|
<section className="xl:col-span-4">
|
||||||
|
<div className="rounded-2xl border-t-4 border-primary bg-surface-container-low p-6 shadow-[0px_20px_40px_rgba(25,28,30,0.06)]">
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-black uppercase tracking-[0.2em] text-primary">
|
||||||
|
Master Categories
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs text-slate-400">Daftar category utama</p>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-primary-fixed px-3 py-1 text-[10px] font-black uppercase tracking-widest text-on-primary-fixed">
|
||||||
|
{categories.length} items
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 space-y-3 rounded-xl bg-white p-4">
|
||||||
|
<input
|
||||||
|
value={categoryName}
|
||||||
|
onChange={(event) => setCategoryName(event.target.value)}
|
||||||
|
placeholder="Nama category"
|
||||||
|
className="w-full rounded-lg border border-surface-container px-4 py-3 text-sm focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={categoryDescription}
|
||||||
|
onChange={(event) => setCategoryDescription(event.target.value)}
|
||||||
|
placeholder="Deskripsi category"
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-lg border border-surface-container px-4 py-3 text-sm focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreateCategory}
|
||||||
|
disabled={savingCategory || !categoryName.trim()}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary-container px-4 py-3 text-sm font-bold text-white transition hover:bg-primary disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-base">add</span>
|
||||||
|
{savingCategory ? "Menyimpan..." : "Add New Category"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{categoryError && (
|
||||||
|
<div className="mb-4 rounded-xl bg-error-container px-4 py-3 text-sm font-semibold text-on-error-container">
|
||||||
|
{categoryError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{loadingCategories ? (
|
||||||
|
<div className="rounded-xl bg-white px-5 py-10 text-center text-slate-400">
|
||||||
|
<span className="material-symbols-outlined animate-spin text-3xl">progress_activity</span>
|
||||||
|
</div>
|
||||||
|
) : categories.length === 0 ? (
|
||||||
|
<div className="rounded-xl bg-white px-5 py-10 text-center text-slate-400">
|
||||||
|
Belum ada category
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
categories.map((category, index) => {
|
||||||
|
const isActive = category.id === selectedCategoryId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedCategoryId(category.id)}
|
||||||
|
className={`group flex w-full items-center justify-between border-l-4 px-5 py-4 text-left transition ${
|
||||||
|
isActive
|
||||||
|
? "border-primary bg-white"
|
||||||
|
: "border-transparent bg-white/50 hover:border-slate-300 hover:bg-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className={`mb-1 block text-xs font-black uppercase tracking-widest ${isActive ? "text-secondary" : "text-slate-400"}`}>
|
||||||
|
CAT-{String(index + 1).padStart(3, "0")}
|
||||||
|
</span>
|
||||||
|
<h4 className={`text-xl font-black tracking-tight ${isActive ? "text-on-surface" : "text-on-surface/70"}`}>
|
||||||
|
{category.name}
|
||||||
|
</h4>
|
||||||
|
{category.description && (
|
||||||
|
<p className="mt-1 text-xs text-slate-500">{category.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`material-symbols-outlined transition ${isActive ? "text-primary" : "text-slate-300 group-hover:text-slate-500"}`}>
|
||||||
|
chevron_right
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="xl:col-span-8">
|
||||||
|
<div className="rounded-2xl bg-surface-container-lowest p-8 shadow-[0px_20px_40px_rgba(25,28,30,0.06)]">
|
||||||
|
<div className="mb-8 flex items-start justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-black uppercase tracking-[0.2em] text-secondary">
|
||||||
|
{selectedCategory ? `Sub-Categories: ${selectedCategory.name}` : "Sub-Categories"}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-3xl font-black tracking-tight text-on-surface">
|
||||||
|
Taxonomy Architecture
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8 rounded-2xl bg-surface-container-low p-5">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<input
|
||||||
|
value={subCategoryName}
|
||||||
|
onChange={(event) => setSubCategoryName(event.target.value)}
|
||||||
|
placeholder="Nama sub-category"
|
||||||
|
disabled={!selectedCategoryId}
|
||||||
|
className="w-full rounded-lg border border-surface-container bg-white px-4 py-3 text-sm focus:border-primary focus:outline-none disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={subCategoryDescription}
|
||||||
|
onChange={(event) => setSubCategoryDescription(event.target.value)}
|
||||||
|
placeholder="Deskripsi sub-category"
|
||||||
|
disabled={!selectedCategoryId}
|
||||||
|
className="w-full rounded-lg border border-surface-container bg-white px-4 py-3 text-sm focus:border-primary focus:outline-none disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex gap-3">
|
||||||
|
<input
|
||||||
|
value={attributeInput}
|
||||||
|
onChange={(event) => setAttributeInput(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
addAttribute();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Tambah attribute lalu Enter"
|
||||||
|
disabled={!selectedCategoryId}
|
||||||
|
className="flex-1 rounded-lg border border-surface-container bg-white px-4 py-3 text-sm focus:border-primary focus:outline-none disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addAttribute}
|
||||||
|
disabled={!selectedCategoryId || !attributeInput.trim()}
|
||||||
|
className="rounded-xl border-2 border-primary px-4 py-3 text-xs font-black uppercase tracking-widest text-primary transition hover:bg-primary hover:text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Add Attribute
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subCategoryAttributes.length > 0 && (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{subCategoryAttributes.map((attribute) => (
|
||||||
|
<button
|
||||||
|
key={attribute}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setSubCategoryAttributes((current) =>
|
||||||
|
current.filter((item) => item !== attribute)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex items-center gap-1 rounded-full bg-surface-container-high px-3 py-1 text-[10px] font-black uppercase tracking-wider text-tertiary"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xs">label</span>
|
||||||
|
{attribute}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreateSubcategory}
|
||||||
|
disabled={savingSubcategory || !selectedCategoryId || !subCategoryName.trim()}
|
||||||
|
className="mt-4 flex items-center gap-2 rounded-xl bg-primary px-5 py-3 text-xs font-black uppercase tracking-widest text-white transition hover:bg-primary-container disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-sm">add_circle</span>
|
||||||
|
{savingSubcategory ? "Menyimpan..." : "Add Sub-Category"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subcategoryError && (
|
||||||
|
<div className="mb-4 rounded-xl bg-error-container px-4 py-3 text-sm font-semibold text-on-error-container">
|
||||||
|
{subcategoryError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedCategoryId ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-surface-container px-6 py-12 text-center text-slate-400">
|
||||||
|
Pilih category di panel kiri untuk melihat sub-category.
|
||||||
|
</div>
|
||||||
|
) : loadingSubcategories ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-surface-container px-6 py-12 text-center text-slate-400">
|
||||||
|
<span className="material-symbols-outlined animate-spin text-3xl">progress_activity</span>
|
||||||
|
</div>
|
||||||
|
) : subcategories.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-surface-container px-6 py-12 text-center text-slate-400">
|
||||||
|
Belum ada sub-category untuk category ini.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{subcategories.map((subcategory) => (
|
||||||
|
<div key={subcategory.id} className="group rounded-xl border-b border-slate-100 pb-5">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-surface-container-low">
|
||||||
|
<span className="material-symbols-outlined text-secondary">category</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="text-lg font-black text-on-surface">
|
||||||
|
{subcategory.name}
|
||||||
|
</h5>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{subcategory.description || `ID: ${subcategory.id}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-surface-container px-3 py-1 text-[10px] font-black uppercase tracking-widest text-slate-500">
|
||||||
|
{subcategory.subCategoryAttributes.length} attributes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2 pl-[3.75rem]">
|
||||||
|
{subcategory.subCategoryAttributes.length > 0 ? (
|
||||||
|
subcategory.subCategoryAttributes.map((attribute) => (
|
||||||
|
<span
|
||||||
|
key={`${subcategory.id}-${attribute}`}
|
||||||
|
className="flex items-center gap-1 rounded-full bg-surface-container px-3 py-1 text-[10px] font-black uppercase tracking-wider text-on-tertiary-fixed-variant"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-xs">label</span>
|
||||||
|
{attribute}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-300">Belum ada attribute</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ const navItems = [
|
|||||||
{ href: "/admin/dashboard", icon: "dashboard", label: "Dashboard" },
|
{ href: "/admin/dashboard", icon: "dashboard", label: "Dashboard" },
|
||||||
{ href: "/admin/news", icon: "newspaper", label: "News" },
|
{ href: "/admin/news", icon: "newspaper", label: "News" },
|
||||||
{ href: "/admin/places", icon: "map", label: "Places" },
|
{ href: "/admin/places", icon: "map", label: "Places" },
|
||||||
|
{ href: "/admin/categories", icon: "category", label: "Categories" },
|
||||||
{ href: "/admin/review", icon: "rate_review", label: "Review" },
|
{ href: "/admin/review", icon: "rate_review", label: "Review" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { API_URL, makeHeaders } from "@/lib/api";
|
||||||
|
|
||||||
|
function normalizeBearerToken(rawToken: string) {
|
||||||
|
if (!rawToken) return "";
|
||||||
|
return rawToken.startsWith("Bearer ") ? rawToken : `Bearer ${rawToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: Promise<{ categoryId: string }> }
|
||||||
|
) {
|
||||||
|
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
|
||||||
|
const { categoryId } = await context.params;
|
||||||
|
const { searchParams } = req.nextUrl;
|
||||||
|
const page = parseInt(searchParams.get("page") || "0", 10) + 1;
|
||||||
|
const size = searchParams.get("size") || "100";
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_URL}/api/v1.0/sub/${categoryId}/categories?page=${page}&size=${size}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: makeHeaders(token, { includeTenantId: true }),
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: Promise<{ categoryId: string }> }
|
||||||
|
) {
|
||||||
|
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
|
||||||
|
const { categoryId } = await context.params;
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/api/v1.0/sub/${categoryId}/categories`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: makeHeaders(token, { includeTenantId: true }),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
}
|
||||||
40
src/app/api/admin/categories/route.ts
Normal file
40
src/app/api/admin/categories/route.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { API_URL, makeHeaders } from "@/lib/api";
|
||||||
|
|
||||||
|
function normalizeBearerToken(rawToken: string) {
|
||||||
|
if (!rawToken) return "";
|
||||||
|
return rawToken.startsWith("Bearer ") ? rawToken : `Bearer ${rawToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
|
||||||
|
const { searchParams } = req.nextUrl;
|
||||||
|
const page = parseInt(searchParams.get("page") || "0", 10) + 1;
|
||||||
|
const size = searchParams.get("size") || "100";
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_URL}/api/v1.0/categories?page=${page}&size=${size}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: makeHeaders(token, { includeTenantId: true }),
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/api/v1.0/categories`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: makeHeaders(token, { includeTenantId: true }),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user