Add seller deleted/unpublish flow and admin product management

This commit is contained in:
2026-05-09 07:56:51 +07:00
parent 37466d42e1
commit cb2a2a9678
13 changed files with 1369 additions and 86 deletions

View File

@ -13,7 +13,8 @@ type TabLabel =
| "International Market" | "International Market"
| "Local Market" | "Local Market"
| "Out Of Stock" | "Out Of Stock"
| "Rejected"; | "Rejected"
| "Deleted";
interface ProductRow { interface ProductRow {
id: string; id: string;
@ -71,31 +72,49 @@ function tabFromQuery(tab: string | null): TabLabel {
return "Out Of Stock"; return "Out Of Stock";
case "rejected": case "rejected":
return "Rejected"; return "Rejected";
case "deleted":
return "Deleted";
default: default:
return "All Product"; return "All Product";
} }
} }
function DeleteConfirmModal({ function ConfirmActionModal({
product, product,
onConfirm, onConfirm,
onCancel, onCancel,
deleting, processing,
d, d,
icon,
iconToneClass,
confirmToneClass,
}: { }: {
product: ProductRow; product: ProductRow;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
deleting: boolean; processing: boolean;
d: { title: string; message: string; productLabel: string; cancel: string; confirm: string; deleting: string }; d: {
title: string;
message: string;
productLabel: string;
cancel: string;
confirm: string;
deleting?: string;
processing?: string;
};
icon: string;
iconToneClass: string;
confirmToneClass: string;
}) { }) {
const processingLabel = d.processing || d.deleting || "Processing...";
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} /> <div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} />
<div className="relative w-full max-w-md rounded-2xl bg-surface-container-lowest border border-outline-variant/10 shadow-2xl p-8 space-y-6"> <div className="relative w-full max-w-md rounded-2xl bg-surface-container-lowest border border-outline-variant/10 shadow-2xl p-8 space-y-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-error-container flex items-center justify-center flex-shrink-0"> <div className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${iconToneClass}`}>
<span className="material-symbols-outlined text-error text-2xl">delete_forever</span> <span className="material-symbols-outlined text-2xl">{icon}</span>
</div> </div>
<div> <div>
<h2 className="text-lg font-black text-on-surface font-headline">{d.title}</h2> <h2 className="text-lg font-black text-on-surface font-headline">{d.title}</h2>
@ -113,7 +132,7 @@ function DeleteConfirmModal({
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
disabled={deleting} disabled={processing}
className="flex-1 px-4 py-3 rounded-xl border border-outline-variant/30 text-on-surface font-black text-sm hover:bg-surface-container-low transition-colors disabled:opacity-50" className="flex-1 px-4 py-3 rounded-xl border border-outline-variant/30 text-on-surface font-black text-sm hover:bg-surface-container-low transition-colors disabled:opacity-50"
> >
{d.cancel} {d.cancel}
@ -121,17 +140,17 @@ function DeleteConfirmModal({
<button <button
type="button" type="button"
onClick={onConfirm} onClick={onConfirm}
disabled={deleting} disabled={processing}
className="flex-1 px-4 py-3 rounded-xl bg-error text-white font-black text-sm hover:bg-error/90 transition-colors disabled:opacity-60 flex items-center justify-center gap-2" className={`flex-1 px-4 py-3 rounded-xl text-white font-black text-sm transition-colors disabled:opacity-60 flex items-center justify-center gap-2 ${confirmToneClass}`}
> >
{deleting ? ( {processing ? (
<> <>
<span className="material-symbols-outlined text-base animate-spin">progress_activity</span> <span className="material-symbols-outlined text-base animate-spin">progress_activity</span>
{d.deleting} {processingLabel}
</> </>
) : ( ) : (
<> <>
<span className="material-symbols-outlined text-base">delete</span> <span className="material-symbols-outlined text-base">{icon}</span>
{d.confirm} {d.confirm}
</> </>
)} )}
@ -149,6 +168,11 @@ function ProductsPageInner() {
const tab = searchParams.get("tab"); const tab = searchParams.get("tab");
const activeTab = tabFromQuery(tab); const activeTab = tabFromQuery(tab);
const isInReviewTab = activeTab === "In Review"; const isInReviewTab = activeTab === "In Review";
const isDeletedTab = activeTab === "Deleted";
const canUnpublish =
activeTab === "All Product" ||
activeTab === "International Market" ||
activeTab === "Local Market";
const [rows, setRows] = useState<ProductRow[]>([]); const [rows, setRows] = useState<ProductRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -157,7 +181,9 @@ function ProductsPageInner() {
const [totalPage, setTotalPage] = useState(0); const [totalPage, setTotalPage] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [deleteTarget, setDeleteTarget] = useState<ProductRow | null>(null); const [deleteTarget, setDeleteTarget] = useState<ProductRow | null>(null);
const [unpublishTarget, setUnpublishTarget] = useState<ProductRow | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [unpublishing, setUnpublishing] = useState(false);
// Reset to page 1 when tab changes // Reset to page 1 when tab changes
useEffect(() => { useEffect(() => {
@ -213,6 +239,47 @@ function ProductsPageInner() {
} }
} }
async function handleUnpublish() {
if (!unpublishTarget) return;
setUnpublishing(true);
try {
const res = await fetch(`/api/products/${unpublishTarget.id}?action=unpublish`, {
method: "PUT",
headers: { "x-auth-token": getToken() },
});
const result = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(result?.responseDesc || p.unpublishDialog.errorGeneric);
}
setUnpublishTarget(null);
window.location.reload();
} catch (err) {
alert(err instanceof Error ? err.message : p.unpublishDialog.errorGeneric);
} finally {
setUnpublishing(false);
}
}
async function handleRestore(productId: string) {
try {
const res = await fetch(`/api/products/${productId}?action=restore`, {
method: "PUT",
headers: { "x-auth-token": getToken() },
});
const result = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(result?.responseDesc || p.restoreError);
}
window.location.reload();
} catch (err) {
alert(err instanceof Error ? err.message : p.restoreError);
}
}
const internationalCount = rows.filter( const internationalCount = rows.filter(
(row) => row.market === "International" (row) => row.market === "International"
).length; ).length;
@ -444,12 +511,14 @@ function ProductsPageInner() {
<td className="px-6 py-4 text-center"> <td className="px-6 py-4 text-center">
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">
{!isInReviewTab ? ( {!isInReviewTab ? (
<Link !isDeletedTab ? (
href={`/products/${product.id}/edit${activeTab === "Draft" ? "?draft=1" : ""}`} <Link
className="rounded-lg bg-primary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-primary/90" href={`/products/${product.id}/edit${activeTab === "Draft" ? "?draft=1" : ""}`}
> className="rounded-lg bg-primary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-primary/90"
{p.edit} >
</Link> {p.edit}
</Link>
) : null
) : null} ) : null}
<Link <Link
href={`/products/${product.id}/detail${activeTab === "Draft" ? "?draft=1" : isInReviewTab ? "?review=1" : ""}`} href={`/products/${product.id}/detail${activeTab === "Draft" ? "?draft=1" : isInReviewTab ? "?review=1" : ""}`}
@ -457,14 +526,35 @@ function ProductsPageInner() {
> >
{p.detail} {p.detail}
</Link> </Link>
<button {isDeletedTab ? (
type="button" <button
onClick={() => setDeleteTarget(product)} type="button"
className="w-7 h-7 flex items-center justify-center rounded-lg text-error hover:bg-error-container transition-colors" onClick={() => handleRestore(product.id)}
title="Hapus" className="rounded-lg bg-tertiary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-tertiary/90"
> >
<span className="material-symbols-outlined text-base">delete</span> {p.restore}
</button> </button>
) : (
<>
{canUnpublish ? (
<button
type="button"
onClick={() => setUnpublishTarget(product)}
className="rounded-lg bg-secondary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-secondary/90"
>
{p.unpublish}
</button>
) : null}
<button
type="button"
onClick={() => setDeleteTarget(product)}
className="w-7 h-7 flex items-center justify-center rounded-lg text-error hover:bg-error-container transition-colors"
title="Hapus"
>
<span className="material-symbols-outlined text-base">delete</span>
</button>
</>
)}
</div> </div>
</td> </td>
</tr> </tr>
@ -505,12 +595,28 @@ function ProductsPageInner() {
</section> </section>
{deleteTarget && ( {deleteTarget && (
<DeleteConfirmModal <ConfirmActionModal
product={deleteTarget} product={deleteTarget}
onConfirm={handleDelete} onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)} onCancel={() => setDeleteTarget(null)}
deleting={deleting} processing={deleting}
d={p.deleteDialog} d={p.deleteDialog}
icon="delete"
iconToneClass="bg-error-container text-error"
confirmToneClass="bg-error hover:bg-error/90"
/>
)}
{unpublishTarget && (
<ConfirmActionModal
product={unpublishTarget}
onConfirm={handleUnpublish}
onCancel={() => setUnpublishTarget(null)}
processing={unpublishing}
d={p.unpublishDialog}
icon="visibility_off"
iconToneClass="bg-secondary-container text-on-secondary-container"
confirmToneClass="bg-secondary hover:bg-secondary/90"
/> />
)} )}
</div> </div>

View File

@ -3,6 +3,7 @@
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { AdminProductSubmenuNav } from "@/components/admin-product-submenu-nav";
const navItems = [ const navItems = [
{ href: "/admin/dashboard", icon: "dashboard", label: "Dashboard" }, { href: "/admin/dashboard", icon: "dashboard", label: "Dashboard" },
@ -10,6 +11,7 @@ const navItems = [
{ href: "/admin/places", icon: "map", label: "Places" }, { href: "/admin/places", icon: "map", label: "Places" },
{ href: "/admin/categories", icon: "category", label: "Categories" }, { href: "/admin/categories", icon: "category", label: "Categories" },
{ href: "/admin/review", icon: "rate_review", label: "Review" }, { href: "/admin/review", icon: "rate_review", label: "Review" },
{ href: "/admin/products", icon: "inventory_2", label: "Product", hasSubmenu: true },
]; ];
export default function AdminLayout({ children }: { children: React.ReactNode }) { export default function AdminLayout({ children }: { children: React.ReactNode }) {
@ -37,18 +39,20 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{navItems.map((item) => { {navItems.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href + "/"); const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
return ( return (
<Link <div key={item.href}>
key={item.href} <Link
href={item.href} href={item.href}
className={`flex items-center px-6 py-3 font-semibold transition-colors ${ className={`flex items-center px-6 py-3 font-semibold transition-colors ${
isActive isActive
? "text-primary font-bold border-r-4 border-primary bg-white" ? "text-primary font-bold border-r-4 border-primary bg-white"
: "text-slate-500 hover:bg-slate-100" : "text-slate-500 hover:bg-slate-100"
}`} }`}
> >
<span className="material-symbols-outlined mr-3 text-xl">{item.icon}</span> <span className="material-symbols-outlined mr-3 text-xl">{item.icon}</span>
<span>{item.label}</span> <span>{item.label}</span>
</Link> </Link>
{item.hasSubmenu ? <AdminProductSubmenuNav /> : null}
</div>
); );
})} })}
</nav> </nav>

View File

@ -0,0 +1,606 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
interface ProductWarehouse {
id?: string;
stock?: number;
city?: string;
province?: string;
country?: string;
}
interface ProductMeasurement {
measurementType?: string;
measurementValue?: string;
price?: string | number;
currency?: string;
weight?: string | number;
weightType?: string;
length?: string | number;
width?: string | number;
height?: string | number;
dimensionType?: string;
isConfigurePromotionPrice?: boolean;
promotionPrice?: string | number;
promotionCurrency?: string;
warehouses?: ProductWarehouse[];
}
interface ProductModel {
name?: string;
sku?: string;
imageId?: string;
warehouses?: ProductWarehouse[];
productMeasurements?: ProductMeasurement[];
}
interface ProductImage {
sequence?: number;
imageId?: string;
}
interface ProductInfoItem {
paramName: string;
paramValue: string;
}
interface ProductCategory {
name?: string;
}
interface ProductSubCategory {
id?: string;
name?: string;
category?: ProductCategory;
}
interface ProductDetail {
name?: string;
state?: string;
description?: string;
imageId?: string;
subCategory?: ProductSubCategory;
isPreOrder?: boolean;
isNew?: boolean;
isEligibleToExport?: boolean;
preOrderDay?: string | number;
productImages?: ProductImage[];
productModels?: ProductModel[];
productKeyWords?: string[];
productFeatures?: string[];
productInformations?: ProductInfoItem[];
categoryInformations?: ProductInfoItem[];
complianceInformation?: {
countryOfOrigin?: string;
safetyWarning?: string;
isDangerousGoodRegulation?: boolean;
};
warrantyInformation?: {
type?: string;
duration?: string | number;
durationType?: string;
};
seller?: {
id?: string;
name?: string;
imageId?: string;
};
}
function getToken() {
if (typeof window === "undefined") return "";
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
}
function imageUrl(imageId?: string | null) {
if (!imageId) return null;
if (imageId.startsWith("http")) return imageId;
return `${API_BASE}/api/v1.0/file/image/${imageId}`;
}
function SectionHeader({ step, title }: { step: string; title: string }) {
return (
<div className="mb-6 flex items-center gap-4">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary text-xs font-black text-white shadow-md shadow-primary/20">
{step}
</div>
<h2 className="font-headline text-xl font-black tracking-tight text-on-surface">{title}</h2>
</div>
);
}
function Row({ label, value }: { label: string; value?: string | number | boolean | null }) {
if (value === "" || value === undefined || value === null) return null;
const display = typeof value === "boolean" ? (value ? "Ya" : "Tidak") : String(value);
return (
<div className="flex justify-between gap-4 border-b border-surface-container py-2 text-sm last:border-0">
<span className="flex-shrink-0 font-medium text-on-surface-variant">{label}</span>
<span className="text-right font-semibold text-on-surface">{display}</span>
</div>
);
}
function ToggleBadge({ label, value }: { label: string; value: boolean }) {
return (
<div className="flex items-center justify-between rounded-xl bg-surface-container-low p-4">
<span className="text-sm font-bold text-on-surface">{label}</span>
<span className={`rounded-full px-2.5 py-1 text-[10px] font-black uppercase tracking-wider ${value ? "bg-primary/10 text-primary" : "bg-surface-container text-outline"}`}>
{value ? "Ya" : "Tidak"}
</span>
</div>
);
}
function DeleteConfirmModal({
productName,
processing,
onCancel,
onConfirm,
}: {
productName: string;
processing: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm">
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-2xl">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-error-container text-error">
<span className="material-symbols-outlined text-2xl">delete</span>
</div>
<div>
<h2 className="text-lg font-black text-on-surface">Delete Product?</h2>
<p className="mt-1 text-sm text-slate-500">
Produk akan dipindahkan ke daftar deleted.
</p>
</div>
</div>
<div className="mt-6 rounded-xl bg-slate-50 p-4">
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400">Product</p>
<p className="mt-1 text-sm font-bold text-on-surface">{productName}</p>
</div>
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={onCancel}
disabled={processing}
className="flex-1 rounded-xl border border-slate-200 px-4 py-3 text-sm font-black text-on-surface transition-colors hover:bg-slate-50 disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={processing}
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-error px-4 py-3 text-sm font-black text-white transition-colors hover:bg-error/90 disabled:opacity-60"
>
{processing ? (
<>
<span className="material-symbols-outlined animate-spin text-base">progress_activity</span>
Deleting...
</>
) : (
<>
<span className="material-symbols-outlined text-base">delete</span>
Yes, Delete
</>
)}
</button>
</div>
</div>
</div>
);
}
export default function AdminProductDetailPage() {
const params = useParams<{ productId: string }>();
const router = useRouter();
const [product, setProduct] = useState<ProductDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
if (!params.productId) return;
fetch(`/api/admin/products/${params.productId}`, {
headers: { "x-auth-token": getToken() },
})
.then(async (res) => {
const json = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(json?.responseDesc || "Gagal memuat detail produk");
}
setProduct(json?.data || json);
})
.catch((err) => setError(err instanceof Error ? err.message : "Gagal memuat detail produk"))
.finally(() => setLoading(false));
}, [params.productId]);
async function handleDelete() {
if (!params.productId) return;
setDeleting(true);
try {
const res = await fetch(`/api/admin/products/${params.productId}`, {
method: "DELETE",
headers: { "x-auth-token": getToken() },
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(json?.responseDesc || "Gagal menghapus produk");
}
router.push("/admin/products");
} catch (err) {
alert(err instanceof Error ? err.message : "Gagal menghapus produk");
setDeleting(false);
setShowDeleteConfirm(false);
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-32">
<p className="text-sm font-semibold text-on-surface-variant">Memuat detail produk...</p>
</div>
);
}
if (error || !product) {
return (
<div className="rounded-xl border border-error/20 bg-error-container p-6 text-sm font-semibold text-on-error-container">
{error || "Produk tidak ditemukan"}
</div>
);
}
const models = Array.isArray(product.productModels) ? product.productModels : [];
const keywords = Array.isArray(product.productKeyWords) ? product.productKeyWords.filter(Boolean) : [];
const features = Array.isArray(product.productFeatures) ? product.productFeatures.filter(Boolean) : [];
const productInfos = Array.isArray(product.productInformations)
? product.productInformations.filter((item) => item.paramName && item.paramValue)
: [];
const categoryInfos = Array.isArray(product.categoryInformations)
? product.categoryInformations.filter((item) => item.paramName && item.paramValue)
: [];
const allImages = [
...(product.imageId ? [product.imageId] : []),
...(Array.isArray(product.productImages)
? product.productImages
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
.map((item) => item.imageId)
.filter((value): value is string => Boolean(value))
: []),
];
return (
<div className="space-y-8 pb-16">
{showDeleteConfirm ? (
<DeleteConfirmModal
productName={product.name || "Product"}
processing={deleting}
onCancel={() => {
if (deleting) return;
setShowDeleteConfirm(false);
}}
onConfirm={handleDelete}
/>
) : null}
<div className="mb-2">
<nav className="mb-4 flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.18em] text-outline">
<Link href="/admin/products" className="hover:text-primary transition-colors">
Product
</Link>
<span className="material-symbols-outlined text-sm">chevron_right</span>
<span className="text-primary">Detail</span>
</nav>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="font-headline text-4xl font-black tracking-tighter text-on-surface">
{product.name || "Product Detail"}
</h1>
<p className="mt-2 font-medium text-on-surface-variant">{product.state || "-"}</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-2 rounded-xl bg-error px-4 py-2 text-sm font-black text-white transition-colors hover:bg-error/90"
>
<span className="material-symbols-outlined text-base">delete</span>
Delete
</button>
<Link
href="/admin/products"
className="flex items-center gap-2 rounded-xl border border-outline-variant/20 px-4 py-2 text-sm font-bold text-on-surface transition-colors hover:bg-surface-container-low"
>
<span className="material-symbols-outlined text-base">arrow_back</span>
Kembali
</Link>
</div>
</div>
</div>
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="01" title="Basic Details" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="rounded-xl bg-surface-container-low p-4">
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Main Category</p>
<p className="text-sm font-semibold text-on-surface">{product.subCategory?.category?.name || "—"}</p>
</div>
<div className="rounded-xl bg-surface-container-low p-4">
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Sub Category</p>
<p className="text-sm font-semibold text-on-surface">{product.subCategory?.name || product.subCategory?.id || "—"}</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-8 xl:grid-cols-12">
<div className="space-y-6 rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm xl:col-span-7">
<SectionHeader step="02" title="Description" />
<div>
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Official Name</p>
<p className="text-base font-semibold text-on-surface">{product.name || "—"}</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ToggleBadge label="Pre-order" value={!!product.isPreOrder} />
<ToggleBadge label="Brand New" value={product.isNew !== false} />
</div>
{product.isPreOrder ? (
<div>
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Pre-order Day</p>
<p className="text-sm font-semibold text-on-surface">{product.preOrderDay || "—"}</p>
</div>
) : null}
<div>
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">Description</p>
<p className="whitespace-pre-line text-sm leading-7 text-on-surface-variant">{product.description || "—"}</p>
</div>
{features.length ? (
<div>
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-outline">Key Features</p>
<div className="flex flex-wrap gap-2">
{features.map((feature) => (
<span
key={feature}
className="rounded-full bg-secondary-fixed px-3 py-1 text-xs font-bold text-on-secondary-fixed"
>
{feature}
</span>
))}
</div>
</div>
) : null}
{keywords.length ? (
<div>
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-outline">Keywords</p>
<div className="flex flex-wrap gap-2">
{keywords.map((keyword) => (
<span
key={keyword}
className="rounded-full bg-primary/10 px-3 py-1 text-xs font-bold text-primary"
>
{keyword}
</span>
))}
</div>
</div>
) : null}
</div>
<div className="space-y-6 xl:col-span-5">
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="03" title="Gallery" />
{allImages.length ? (
<div className="grid grid-cols-2 gap-3">
{allImages.map((imageId, index) => {
const src = imageUrl(imageId);
if (!src) return null;
return (
// eslint-disable-next-line @next/next/no-img-element
<img
key={`${imageId}-${index}`}
src={src}
alt={`${product.name || "product"}-${index + 1}`}
className="h-32 w-full rounded-xl border border-surface-container object-cover"
/>
);
})}
</div>
) : (
<div className="flex h-32 items-center justify-center rounded-xl bg-surface-container-low text-sm font-semibold text-outline">
Tidak ada gambar
</div>
)}
</div>
{product.seller ? (
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="04" title="Seller" />
<div className="flex items-center gap-4">
{imageUrl(product.seller.imageId) ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageUrl(product.seller.imageId) || ""}
alt={product.seller.name || "seller"}
className="h-14 w-14 rounded-full border border-surface-container object-cover"
/>
) : (
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
<span className="material-symbols-outlined">storefront</span>
</div>
)}
<div>
<p className="font-black text-on-surface">{product.seller.name || "-"}</p>
<p className="mt-0.5 text-xs text-outline">ID: {product.seller.id || "-"}</p>
</div>
</div>
</div>
) : null}
</div>
</div>
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="05" title="Models and Pricing" />
{models.length ? (
<div className="space-y-4">
{models.map((model, index) => (
<div key={`${model.sku || model.name || index}`} className="rounded-2xl border border-surface-container p-5">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<p className="text-sm font-black text-on-surface">{model.name || `Model ${index + 1}`}</p>
<p className="mt-1 text-xs text-outline">SKU: {model.sku || "-"}</p>
</div>
</div>
<div className="space-y-4">
{(Array.isArray(model.productMeasurements) ? model.productMeasurements : []).map((measurement, measurementIndex) => (
<div key={`${measurement.measurementType || measurementIndex}`} className="rounded-xl bg-surface-container-low p-4">
<Row
label="Measurement"
value={[
measurement.measurementType,
measurement.measurementValue,
]
.filter(Boolean)
.join(" - ") || "—"}
/>
<Row
label="Price"
value={
measurement.price != null
? `${measurement.currency || "IDR"} ${Number(measurement.price).toLocaleString("id-ID")}`
: "—"
}
/>
<Row
label="Weight"
value={
measurement.weight != null
? `${measurement.weight} ${measurement.weightType || ""}`
: undefined
}
/>
<Row
label="Dimension"
value={
[measurement.length, measurement.width, measurement.height].some(Boolean)
? `${[measurement.length, measurement.width, measurement.height].filter(Boolean).join(" x ")} ${measurement.dimensionType || ""}`
: undefined
}
/>
{measurement.isConfigurePromotionPrice ? (
<Row
label="Promotion Price"
value={
measurement.promotionPrice != null
? `${measurement.promotionCurrency || measurement.currency || "IDR"} ${Number(measurement.promotionPrice).toLocaleString("id-ID")}`
: undefined
}
/>
) : null}
{Array.isArray(measurement.warehouses) && measurement.warehouses.length ? (
<div className="mt-3 border-t border-surface-container pt-3">
<p className="mb-2 text-[10px] font-black uppercase tracking-widest text-outline">Warehouses</p>
{measurement.warehouses.map((warehouse, warehouseIndex) => (
<div key={`${warehouse.id || warehouseIndex}`} className="flex justify-between py-1 text-sm">
<span className="text-on-surface-variant">
{[warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || "-"}
</span>
<span className="font-bold text-on-surface">{warehouse.stock ?? 0} unit</span>
</div>
))}
</div>
) : null}
</div>
))}
</div>
</div>
))}
</div>
) : (
<div className="rounded-xl bg-surface-container-low p-4 text-sm font-semibold text-outline">Tidak ada model produk</div>
)}
</div>
<div className="grid grid-cols-1 gap-8 xl:grid-cols-2">
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="06" title="Additional Information" />
{productInfos.length || categoryInfos.length ? (
<div className="space-y-6">
{productInfos.length ? (
<div>
<p className="mb-3 text-[10px] font-black uppercase tracking-widest text-outline">Product Information</p>
<div className="space-y-1">
{productInfos.map((item) => (
<Row key={`${item.paramName}-${item.paramValue}`} label={item.paramName} value={item.paramValue} />
))}
</div>
</div>
) : null}
{categoryInfos.length ? (
<div>
<p className="mb-3 text-[10px] font-black uppercase tracking-widest text-outline">Category Information</p>
<div className="space-y-1">
{categoryInfos.map((item) => (
<Row key={`${item.paramName}-${item.paramValue}`} label={item.paramName} value={item.paramValue} />
))}
</div>
</div>
) : null}
</div>
) : (
<div className="rounded-xl bg-surface-container-low p-4 text-sm font-semibold text-outline">
Tidak ada informasi tambahan
</div>
)}
</div>
<div className="space-y-8">
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="07" title="Compliance" />
<div className="space-y-1">
<Row label="Country of Origin" value={product.complianceInformation?.countryOfOrigin} />
<Row label="Safety Warning" value={product.complianceInformation?.safetyWarning} />
<Row label="Dangerous Goods" value={product.complianceInformation?.isDangerousGoodRegulation} />
<Row label="Eligible to Export" value={product.isEligibleToExport} />
</div>
</div>
<div className="rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-sm">
<SectionHeader step="08" title="Warranty" />
<div className="space-y-1">
<Row label="Type" value={product.warrantyInformation?.type} />
<Row
label="Duration"
value={
product.warrantyInformation?.duration
? `${product.warrantyInformation.duration} ${product.warrantyInformation.durationType || ""}`
: undefined
}
/>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,394 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
interface ProductRow {
id: string;
name: string;
image: string | null;
market: string | null;
minPrice: number | null;
maxPrice: number | null;
totalStock: number | null;
}
function getToken() {
if (typeof window === "undefined") return "";
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
}
function formatPrice(min: number | null, max: number | null) {
if (min == null && max == null) return "—";
if (min === max || max == null) return `Rp ${(min ?? 0).toLocaleString("id-ID")}`;
return `Rp ${(min ?? 0).toLocaleString("id-ID")} - Rp ${max.toLocaleString("id-ID")}`;
}
function marketBadge(market: string | null) {
if (!market) return "bg-slate-100 text-slate-500";
const normalized = market.toLowerCase();
if (normalized === "international") return "bg-secondary-fixed text-on-secondary-fixed";
return "bg-tertiary-fixed text-on-tertiary-fixed";
}
function ConfirmModal({
title,
message,
product,
processing,
onCancel,
onConfirm,
confirmLabel,
processingLabel,
confirmClassName,
icon,
iconClassName,
}: {
title: string;
message: string;
product: ProductRow;
processing: boolean;
onCancel: () => void;
onConfirm: () => void;
confirmLabel: string;
processingLabel: string;
confirmClassName: string;
icon: string;
iconClassName: string;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm">
<div className="w-full max-w-md rounded-2xl bg-white p-8 shadow-2xl">
<div className="flex items-start gap-4">
<div className={`flex h-12 w-12 items-center justify-center rounded-xl ${iconClassName}`}>
<span className="material-symbols-outlined text-2xl">{icon}</span>
</div>
<div>
<h2 className="text-lg font-black text-on-surface">{title}</h2>
<p className="mt-1 text-sm text-slate-500">{message}</p>
</div>
</div>
<div className="mt-6 rounded-xl bg-slate-50 p-4">
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400">Product</p>
<p className="mt-1 text-sm font-bold text-on-surface">{product.name}</p>
<p className="mt-0.5 text-xs text-slate-400">ID: {product.id}</p>
</div>
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={onCancel}
disabled={processing}
className="flex-1 rounded-xl border border-slate-200 px-4 py-3 text-sm font-black text-on-surface transition-colors hover:bg-slate-50 disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={processing}
className={`flex flex-1 items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-black text-white transition-colors disabled:opacity-60 ${confirmClassName}`}
>
{processing ? (
<>
<span className="material-symbols-outlined animate-spin text-base">progress_activity</span>
{processingLabel}
</>
) : (
<>
<span className="material-symbols-outlined text-base">{icon}</span>
{confirmLabel}
</>
)}
</button>
</div>
</div>
</div>
);
}
export default function AdminProductsPage() {
const searchParams = useSearchParams();
const tab = searchParams.get("tab");
const isDeletedTab = tab === "deleted";
const [rows, setRows] = useState<ProductRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [page, setPage] = useState(0);
const [totalItem, setTotalItem] = useState(0);
const [totalPage, setTotalPage] = useState(1);
const [deleteTarget, setDeleteTarget] = useState<ProductRow | null>(null);
const [restoreTarget, setRestoreTarget] = useState<ProductRow | null>(null);
const [processingDelete, setProcessingDelete] = useState(false);
const [processingRestore, setProcessingRestore] = useState(false);
const pageSize = 20;
useEffect(() => {
setPage(0);
}, [tab]);
useEffect(() => {
async function load() {
setLoading(true);
setError("");
try {
const params = new URLSearchParams({
page: String(page),
size: String(pageSize),
});
if (tab) params.set("tab", tab);
const res = await fetch(`/api/admin/products?${params.toString()}`, {
headers: { "x-auth-token": getToken() },
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.responseDesc || "Gagal memuat produk");
}
setRows(Array.isArray(data?.rows) ? data.rows : Array.isArray(data?.data?.rows) ? data.data.rows : []);
setTotalItem(data?.totalItem ?? data?.data?.totalItem ?? 0);
setTotalPage(data?.totalPage ?? data?.data?.totalPage ?? 1);
} catch (err) {
setError(err instanceof Error ? err.message : "Gagal memuat produk");
} finally {
setLoading(false);
}
}
load();
}, [page, pageSize, tab]);
async function handleDelete() {
if (!deleteTarget) return;
setProcessingDelete(true);
try {
const res = await fetch(`/api/admin/products/${deleteTarget.id}`, {
method: "DELETE",
headers: { "x-auth-token": getToken() },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.responseDesc || "Gagal menghapus produk");
}
setDeleteTarget(null);
window.location.reload();
} catch (err) {
alert(err instanceof Error ? err.message : "Gagal menghapus produk");
} finally {
setProcessingDelete(false);
}
}
async function handleRestore() {
if (!restoreTarget) return;
setProcessingRestore(true);
try {
const res = await fetch(`/api/admin/products/${restoreTarget.id}`, {
method: "PUT",
headers: { "x-auth-token": getToken() },
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.responseDesc || "Gagal restore produk");
}
setRestoreTarget(null);
window.location.reload();
} catch (err) {
alert(err instanceof Error ? err.message : "Gagal restore produk");
} finally {
setProcessingRestore(false);
}
}
return (
<div className="space-y-8">
<div className="flex items-end justify-between">
<div>
<nav className="mb-2 flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">
<span>Catalog</span>
<span className="material-symbols-outlined text-[10px]">chevron_right</span>
<span className="text-primary">Product</span>
</nav>
<h1 className="text-4xl font-black tracking-tight text-on-surface">
{isDeletedTab ? "Deleted Products" : "All Products"}
</h1>
<p className="mt-2 text-sm font-medium text-slate-500">
{isDeletedTab
? "Produk yang sudah dihapus dan dapat dipulihkan kembali."
: "Monitoring seluruh katalog produk yang tersedia di platform."}
</p>
</div>
<div className="rounded-2xl bg-slate-900 px-6 py-5 text-white">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-white/60">Total Item</p>
<p className="mt-2 text-3xl font-black tracking-tight">{loading ? "—" : totalItem}</p>
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-slate-100 bg-white shadow-xl shadow-slate-200/40">
<div className="flex items-center justify-between border-b border-slate-100 bg-slate-50/80 px-6 py-4">
<div className="rounded-xl bg-white px-4 py-2 text-xs font-black uppercase tracking-widest text-slate-500 shadow-sm">
{isDeletedTab ? "Deleted" : "All Product"}
</div>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
{isDeletedTab ? "Restore only" : "Detail and delete"}
</p>
</div>
{loading ? (
<div className="p-16 text-center text-slate-400">
<span className="material-symbols-outlined mb-4 block animate-spin text-4xl">progress_activity</span>
<p className="text-sm font-medium">Memuat data...</p>
</div>
) : error ? (
<div className="p-16 text-center text-error">
<span className="material-symbols-outlined mb-4 block text-4xl">error</span>
<p className="text-sm font-medium">{error}</p>
</div>
) : rows.length === 0 ? (
<div className="p-16 text-center text-slate-400">
<span className="material-symbols-outlined mb-4 block text-4xl">inventory_2</span>
<p className="text-sm font-medium">Tidak ada produk ditemukan</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left">
<thead>
<tr className="border-b border-slate-100 bg-slate-50">
{["Product", "Market", "Price", "Stock", "Actions"].map((header) => (
<th
key={header}
className={`px-6 py-4 text-[11px] font-black uppercase tracking-widest text-slate-500 ${
header === "Stock" ? "text-center" : header === "Actions" ? "text-right" : ""
}`}
>
{header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{rows.map((product) => (
<tr key={product.id} className="group hover:bg-surface-container-low transition-colors">
<td className="px-6 py-5">
<div>
<p className="font-extrabold text-on-surface transition-colors group-hover:text-primary">
{product.name}
</p>
<p className="mt-1 text-[10px] font-medium text-slate-400">ID: {product.id.slice(0, 8)}</p>
</div>
</td>
<td className="px-6 py-5">
<span className={`inline-flex rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-wider ${marketBadge(product.market)}`}>
{product.market || "—"}
</span>
</td>
<td className="px-6 py-5">
<span className="font-bold text-on-surface">{formatPrice(product.minPrice, product.maxPrice)}</span>
</td>
<td className="px-6 py-5 text-center">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-sm font-bold text-on-surface">
{product.totalStock ?? 0}
</span>
</td>
<td className="px-6 py-5">
<div className="flex justify-end gap-2">
{!isDeletedTab ? (
<Link
href={`/admin/products/${product.id}`}
className="rounded-xl px-4 py-2 text-[10px] font-black uppercase tracking-widest text-primary transition-colors hover:bg-primary/5"
>
Detail
</Link>
) : null}
{isDeletedTab ? (
<button
type="button"
onClick={() => setRestoreTarget(product)}
className="rounded-xl bg-tertiary px-4 py-2 text-[10px] font-black uppercase tracking-widest text-white transition-colors hover:bg-tertiary/90"
>
Restore
</button>
) : (
<button
type="button"
onClick={() => setDeleteTarget(product)}
className="rounded-xl bg-error px-4 py-2 text-[10px] font-black uppercase tracking-widest text-white transition-colors hover:bg-error/90"
>
Delete
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="flex items-center justify-between border-t border-slate-100 bg-slate-50/60 px-6 py-4">
<p className="text-[11px] font-bold uppercase tracking-widest text-slate-500">
Total Item: <span className="text-on-surface">{totalItem}</span>
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((current) => Math.max(0, current - 1))}
disabled={page === 0 || loading}
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
>
<span className="material-symbols-outlined text-sm">chevron_left</span>
</button>
<button className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-xs font-black text-white">
{page + 1}
</button>
<span className="px-2 text-[10px] font-bold text-slate-500">/ {Math.max(totalPage, 1)}</span>
<button
onClick={() => setPage((current) => Math.min(Math.max(totalPage - 1, 0), current + 1))}
disabled={page >= totalPage - 1 || loading}
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
>
<span className="material-symbols-outlined text-sm">chevron_right</span>
</button>
</div>
</div>
</div>
{deleteTarget ? (
<ConfirmModal
title="Delete Product?"
message="Produk akan dipindahkan ke daftar deleted."
product={deleteTarget}
processing={processingDelete}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDelete}
confirmLabel="Yes, Delete"
processingLabel="Deleting..."
confirmClassName="bg-error hover:bg-error/90"
icon="delete"
iconClassName="bg-error-container text-error"
/>
) : null}
{restoreTarget ? (
<ConfirmModal
title="Restore Product?"
message="Produk akan dikembalikan ke daftar aktif."
product={restoreTarget}
processing={processingRestore}
onCancel={() => setRestoreTarget(null)}
onConfirm={handleRestore}
confirmLabel="Yes, Restore"
processingLabel="Restoring..."
confirmClassName="bg-tertiary hover:bg-tertiary/90"
icon="restore_from_trash"
iconClassName="bg-tertiary-fixed text-on-tertiary-fixed"
/>
) : null}
</div>
);
}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id"; const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id";
@ -199,6 +199,9 @@ function ProductColumn({ product, label, accent }: { product: any; label: string
export default function AdminReviewDetailPage() { export default function AdminReviewDetailPage() {
const params = useParams<{ productId: string }>(); const params = useParams<{ productId: string }>();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const isReadonly = searchParams.get("readonly") === "1";
const backHref = isReadonly ? "/admin/products" : "/admin/review";
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const [product, setProduct] = useState<any>(null); // updated (review) const [product, setProduct] = useState<any>(null); // updated (review)
@ -352,16 +355,20 @@ export default function AdminReviewDetailPage() {
return ( return (
<> <>
{rejectModal} {!isReadonly ? rejectModal : null}
<div className="m-6 space-y-6 pb-10"> <div className="m-6 space-y-6 pb-10">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<nav className="flex mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400 gap-2 items-center"> <nav className="flex mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400 gap-2 items-center">
<button onClick={() => router.push("/admin/review")} className="hover:text-primary transition-colors">Reviews</button> <button onClick={() => router.push(backHref)} className="hover:text-primary transition-colors">
{isReadonly ? "Products" : "Reviews"}
</button>
<span className="material-symbols-outlined text-[10px]">chevron_right</span> <span className="material-symbols-outlined text-[10px]">chevron_right</span>
<span className="text-primary">{isComparison ? "Review Update" : "Review Produk Baru"}</span> <span className="text-primary">
{isReadonly ? "Product Detail" : isComparison ? "Review Update" : "Review Produk Baru"}
</span>
</nav> </nav>
<h1 className="text-3xl font-black tracking-tight text-on-surface">{product.name}</h1> <h1 className="text-3xl font-black tracking-tight text-on-surface">{product.name}</h1>
<div className="flex items-center gap-3 mt-2"> <div className="flex items-center gap-3 mt-2">
@ -421,46 +428,48 @@ export default function AdminReviewDetailPage() {
)} )}
{/* Action bar */} {/* Action bar */}
<div className="flex flex-col items-end gap-2 pt-2"> {!isReadonly ? (
{actionSuccess && ( <div className="flex flex-col items-end gap-2 pt-2">
<div className="w-full p-4 bg-tertiary-fixed text-on-tertiary-fixed-variant rounded-xl flex items-center gap-3 font-semibold text-sm"> {actionSuccess && (
<span className="material-symbols-outlined text-tertiary">check_circle</span> <div className="w-full p-4 bg-tertiary-fixed text-on-tertiary-fixed-variant rounded-xl flex items-center gap-3 font-semibold text-sm">
{actionSuccess} Mengalihkan... <span className="material-symbols-outlined text-tertiary">check_circle</span>
</div> {actionSuccess} Mengalihkan...
)} </div>
{actionError && !showRejectModal && ( )}
<div className="w-full p-4 bg-error-container text-on-error-container rounded-xl flex items-center gap-3 font-semibold text-sm"> {actionError && !showRejectModal && (
<span className="material-symbols-outlined">error</span> <div className="w-full p-4 bg-error-container text-on-error-container rounded-xl flex items-center gap-3 font-semibold text-sm">
{actionError} <span className="material-symbols-outlined">error</span>
</div> {actionError}
)} </div>
{acting && ( )}
<p className="text-xs text-slate-400 flex items-center gap-1.5"> {acting && (
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span> <p className="text-xs text-slate-400 flex items-center gap-1.5">
Memproses review... <span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
</p> Memproses review...
)} </p>
{!actionSuccess && ( )}
<div className="flex items-center gap-4"> {!actionSuccess && (
<button <div className="flex items-center gap-4">
onClick={() => { setShowRejectModal(true); setActionError(""); }} <button
disabled={acting} onClick={() => { setShowRejectModal(true); setActionError(""); }}
className="flex items-center gap-2 px-6 py-3.5 rounded-xl border-2 border-error text-error font-black text-sm uppercase tracking-[0.15em] hover:bg-error/5 transition-colors disabled:opacity-40" disabled={acting}
> className="flex items-center gap-2 px-6 py-3.5 rounded-xl border-2 border-error text-error font-black text-sm uppercase tracking-[0.15em] hover:bg-error/5 transition-colors disabled:opacity-40"
<span className="material-symbols-outlined text-base">block</span> >
{isComparison ? "Reject Update" : "Reject Product"} <span className="material-symbols-outlined text-base">block</span>
</button> {isComparison ? "Reject Update" : "Reject Product"}
<button </button>
onClick={() => submitReview("accept")} <button
disabled={acting} onClick={() => submitReview("accept")}
className="flex items-center gap-2 bg-gradient-to-r from-primary to-primary-container text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.15em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all disabled:opacity-60 disabled:hover:translate-y-0" disabled={acting}
> className="flex items-center gap-2 bg-gradient-to-r from-primary to-primary-container text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.15em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all disabled:opacity-60 disabled:hover:translate-y-0"
<span className="material-symbols-outlined text-base">check_circle</span> >
{acting ? "Memproses..." : isComparison ? "Accept Update" : "Accept Product"} <span className="material-symbols-outlined text-base">check_circle</span>
</button> {acting ? "Memproses..." : isComparison ? "Accept Update" : "Accept Product"}
</div> </button>
)} </div>
</div> )}
</div>
) : null}
</div> </div>
</> </>
); );

View File

@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { API_URL, makeHeaders } from "@/lib/api";
export async function GET(
req: NextRequest,
context: { params: Promise<{ productId: string }> }
) {
const token = req.headers.get("x-auth-token") || "";
const { productId } = await context.params;
const res = await fetch(`${API_URL}/api/v1.0/product/${productId}`, {
headers: makeHeaders(token),
cache: "no-store",
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ productId: string }> }
) {
const token = req.headers.get("x-auth-token") || "";
const { productId } = await context.params;
const res = await fetch(`${API_URL}/api/v1.0/admin/product/${productId}`, {
method: "DELETE",
headers: makeHeaders(token),
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}
export async function PUT(
req: NextRequest,
context: { params: Promise<{ productId: string }> }
) {
const token = req.headers.get("x-auth-token") || "";
const { productId } = await context.params;
const res = await fetch(`${API_URL}/api/v1.0/admin/product/${productId}/restore`, {
method: "PUT",
headers: makeHeaders(token),
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}

View File

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { API_URL, makeHeaders } from "@/lib/api";
export async function GET(req: NextRequest) {
const token = req.headers.get("x-auth-token") || "";
const { searchParams } = req.nextUrl;
const page = parseInt(searchParams.get("page") || "0", 10) + 1;
const size = searchParams.get("size") || "20";
const tab = searchParams.get("tab");
const endpoint =
tab === "deleted"
? "/api/v1.0/admin/deleted/product"
: "/api/v1.0/product";
const res = await fetch(`${API_URL}${endpoint}?page=${page}&size=${size}`, {
headers: makeHeaders(token),
cache: "no-store",
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}

View File

@ -38,8 +38,25 @@ export async function PUT(
try { try {
const token = normalizeBearerToken(req.headers.get("x-auth-token") || ""); const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
const { productId } = await context.params; const { productId } = await context.params;
const body = await req.json();
const isDraft = req.nextUrl.searchParams.get("draft") === "1"; const isDraft = req.nextUrl.searchParams.get("draft") === "1";
const action = req.nextUrl.searchParams.get("action");
if (action === "unpublish" || action === "restore") {
const endpoint =
action === "unpublish"
? `${API_URL}/api/v1.0/seller/product/${productId}/unpublish`
: `${API_URL}/api/v1.0/seller/product/${productId}/restore`;
const res = await fetch(endpoint, {
method: "PUT",
headers: makeHeaders(token),
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
}
const body = await req.json();
const endpoint = isDraft const endpoint = isDraft
? `${API_URL}/api/v1.0/product/draft/${productId}` ? `${API_URL}/api/v1.0/product/draft/${productId}`

View File

@ -13,6 +13,7 @@ export async function GET(req: NextRequest) {
"local-market": "/api/v1.0/seller/local/product", "local-market": "/api/v1.0/seller/local/product",
"out-of-stock": "/api/v1.0/seller/outofstock/product", "out-of-stock": "/api/v1.0/seller/outofstock/product",
rejected: "/api/v1.0/seller/reject/product", rejected: "/api/v1.0/seller/reject/product",
deleted: "/api/v1.0/seller/deleted/product",
}; };
const endpoint = endpointMap[tab || ""] || "/api/v1.0/seller/product"; const endpoint = endpointMap[tab || ""] || "/api/v1.0/seller/product";

View File

@ -0,0 +1,47 @@
"use client";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
const adminProductSubmenu = [
{ label: "All Product", href: "/admin/products" },
{ label: "Deleted", href: "/admin/products?tab=deleted" },
];
export function AdminProductSubmenuNav() {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentTab = searchParams.get("tab") ?? "";
if (pathname !== "/admin/products" && !pathname.startsWith("/admin/products/")) {
return null;
}
return (
<div className="ml-12 mt-1 space-y-0.5">
{adminProductSubmenu.map((submenu) => {
const submenuTab = new URLSearchParams(
submenu.href.split("?")[1] || ""
).get("tab") ?? "";
const isAllProduct = submenu.href === "/admin/products";
const isActive =
pathname === "/admin/products" &&
(isAllProduct ? currentTab === "" : submenuTab === currentTab);
return (
<Link
key={submenu.href}
href={submenu.href}
className={`block rounded-lg px-4 py-2 text-sm font-semibold transition-colors ${
isActive
? "bg-white text-primary shadow-sm"
: "text-slate-500 hover:bg-slate-100 hover:text-primary"
}`}
>
{submenu.label}
</Link>
);
})}
</div>
);
}

View File

@ -12,6 +12,7 @@ const productSubmenu = [
{ label: "Local Market", href: "/products?tab=local-market" }, { label: "Local Market", href: "/products?tab=local-market" },
{ label: "Out Of Stock", href: "/products?tab=out-of-stock" }, { label: "Out Of Stock", href: "/products?tab=out-of-stock" },
{ label: "Rejected", href: "/products?tab=rejected" }, { label: "Rejected", href: "/products?tab=rejected" },
{ label: "Deleted", href: "/products?tab=deleted" },
]; ];
function ProductSubmenuNavInner() { function ProductSubmenuNavInner() {

View File

@ -358,6 +358,8 @@ export const en = {
empty: "No products found.", empty: "No products found.",
edit: "Edit", edit: "Edit",
detail: "Detail", detail: "Detail",
restore: "Restore",
unpublish: "Unpublish",
table: { table: {
product: "Product", product: "Product",
price: "Price", price: "Price",
@ -377,6 +379,16 @@ export const en = {
deleting: "Deleting...", deleting: "Deleting...",
errorGeneric: "Failed to delete product", errorGeneric: "Failed to delete product",
}, },
unpublishDialog: {
title: "Unpublish Product?",
message: "The product will be removed from the active catalog and can be published again later.",
productLabel: "Product to unpublish",
cancel: "Cancel",
confirm: "Yes, Unpublish",
processing: "Processing...",
errorGeneric: "Failed to unpublish product",
},
restoreError: "Failed to restore product",
tabs: { tabs: {
allProduct: "All Product", allProduct: "All Product",
draft: "Draft", draft: "Draft",
@ -385,6 +397,7 @@ export const en = {
localMarket: "Local Market", localMarket: "Local Market",
outOfStock: "Out of Stock", outOfStock: "Out of Stock",
rejected: "Rejected", rejected: "Rejected",
deleted: "Deleted",
}, },
}, },
productNew: { productNew: {

View File

@ -359,6 +359,8 @@ export const id = {
empty: "Tidak ada produk ditemukan.", empty: "Tidak ada produk ditemukan.",
edit: "Edit", edit: "Edit",
detail: "Detail", detail: "Detail",
restore: "Restore",
unpublish: "Unpublish",
table: { table: {
product: "Produk", product: "Produk",
price: "Harga", price: "Harga",
@ -378,6 +380,16 @@ export const id = {
deleting: "Menghapus...", deleting: "Menghapus...",
errorGeneric: "Gagal menghapus produk", errorGeneric: "Gagal menghapus produk",
}, },
unpublishDialog: {
title: "Unpublish Produk?",
message: "Produk akan dihapus dari katalog aktif dan bisa dipublish kembali nanti.",
productLabel: "Produk yang akan di-unpublish",
cancel: "Batal",
confirm: "Ya, Unpublish",
processing: "Memproses...",
errorGeneric: "Gagal unpublish produk",
},
restoreError: "Gagal restore produk",
tabs: { tabs: {
allProduct: "Semua Produk", allProduct: "Semua Produk",
draft: "Draft", draft: "Draft",
@ -386,6 +398,7 @@ export const id = {
localMarket: "Pasar Lokal", localMarket: "Pasar Lokal",
outOfStock: "Habis Stok", outOfStock: "Habis Stok",
rejected: "Ditolak", rejected: "Ditolak",
deleted: "Deleted",
}, },
}, },
productNew: { productNew: {