From cb2a2a9678d14e78117f84d2b2a18ffdddc8ffa1 Mon Sep 17 00:00:00 2001 From: Wira Irawan Date: Sat, 9 May 2026 07:56:51 +0700 Subject: [PATCH] Add seller deleted/unpublish flow and admin product management --- src/app/(dashboard)/products/page.tsx | 164 ++++- src/app/admin/layout.tsx | 28 +- src/app/admin/products/[productId]/page.tsx | 606 ++++++++++++++++++ src/app/admin/products/page.tsx | 394 ++++++++++++ src/app/admin/review/[productId]/page.tsx | 97 +-- .../api/admin/products/[productId]/route.ts | 50 ++ src/app/api/admin/products/route.ts | 22 + src/app/api/products/[productId]/route.ts | 19 +- src/app/api/products/route.ts | 1 + src/components/admin-product-submenu-nav.tsx | 47 ++ src/components/product-submenu-nav.tsx | 1 + src/lib/translations/en.ts | 13 + src/lib/translations/id.ts | 13 + 13 files changed, 1369 insertions(+), 86 deletions(-) create mode 100644 src/app/admin/products/[productId]/page.tsx create mode 100644 src/app/admin/products/page.tsx create mode 100644 src/app/api/admin/products/[productId]/route.ts create mode 100644 src/app/api/admin/products/route.ts create mode 100644 src/components/admin-product-submenu-nav.tsx diff --git a/src/app/(dashboard)/products/page.tsx b/src/app/(dashboard)/products/page.tsx index f48b176..614612d 100644 --- a/src/app/(dashboard)/products/page.tsx +++ b/src/app/(dashboard)/products/page.tsx @@ -13,7 +13,8 @@ type TabLabel = | "International Market" | "Local Market" | "Out Of Stock" - | "Rejected"; + | "Rejected" + | "Deleted"; interface ProductRow { id: string; @@ -71,31 +72,49 @@ function tabFromQuery(tab: string | null): TabLabel { return "Out Of Stock"; case "rejected": return "Rejected"; + case "deleted": + return "Deleted"; default: return "All Product"; } } -function DeleteConfirmModal({ +function ConfirmActionModal({ product, onConfirm, onCancel, - deleting, + processing, d, + icon, + iconToneClass, + confirmToneClass, }: { product: ProductRow; onConfirm: () => void; onCancel: () => void; - deleting: boolean; - d: { title: string; message: string; productLabel: string; cancel: string; confirm: string; deleting: string }; + processing: boolean; + 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 (
-
- delete_forever +
+ {icon}

{d.title}

@@ -113,7 +132,7 @@ function DeleteConfirmModal({ + {isDeletedTab ? ( + + ) : ( + <> + {canUnpublish ? ( + + ) : null} + + + )}
@@ -505,12 +595,28 @@ function ProductsPageInner() { {deleteTarget && ( - setDeleteTarget(null)} - deleting={deleting} + processing={deleting} d={p.deleteDialog} + icon="delete" + iconToneClass="bg-error-container text-error" + confirmToneClass="bg-error hover:bg-error/90" + /> + )} + + {unpublishTarget && ( + 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" /> )}
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 136e97f..0aac660 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -3,6 +3,7 @@ import Image from "next/image"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; +import { AdminProductSubmenuNav } from "@/components/admin-product-submenu-nav"; const navItems = [ { href: "/admin/dashboard", icon: "dashboard", label: "Dashboard" }, @@ -10,6 +11,7 @@ const navItems = [ { href: "/admin/places", icon: "map", label: "Places" }, { href: "/admin/categories", icon: "category", label: "Categories" }, { 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 }) { @@ -37,18 +39,20 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) {navItems.map((item) => { const isActive = pathname === item.href || pathname.startsWith(item.href + "/"); return ( - - {item.icon} - {item.label} - +
+ + {item.icon} + {item.label} + + {item.hasSubmenu ? : null} +
); })} diff --git a/src/app/admin/products/[productId]/page.tsx b/src/app/admin/products/[productId]/page.tsx new file mode 100644 index 0000000..fca6f17 --- /dev/null +++ b/src/app/admin/products/[productId]/page.tsx @@ -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 ( +
+
+ {step} +
+

{title}

+
+ ); +} + +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 ( +
+ {label} + {display} +
+ ); +} + +function ToggleBadge({ label, value }: { label: string; value: boolean }) { + return ( +
+ {label} + + {value ? "Ya" : "Tidak"} + +
+ ); +} + +function DeleteConfirmModal({ + productName, + processing, + onCancel, + onConfirm, +}: { + productName: string; + processing: boolean; + onCancel: () => void; + onConfirm: () => void; +}) { + return ( +
+
+
+
+ delete +
+
+

Delete Product?

+

+ Produk akan dipindahkan ke daftar deleted. +

+
+
+ +
+

Product

+

{productName}

+
+ +
+ + +
+
+
+ ); +} + +export default function AdminProductDetailPage() { + const params = useParams<{ productId: string }>(); + const router = useRouter(); + const [product, setProduct] = useState(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 ( +
+

Memuat detail produk...

+
+ ); + } + + if (error || !product) { + return ( +
+ {error || "Produk tidak ditemukan"} +
+ ); + } + + 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 ( +
+ {showDeleteConfirm ? ( + { + if (deleting) return; + setShowDeleteConfirm(false); + }} + onConfirm={handleDelete} + /> + ) : null} + +
+ +
+
+

+ {product.name || "Product Detail"} +

+

{product.state || "-"}

+
+
+ + + arrow_back + Kembali + +
+
+
+ +
+ +
+
+

Main Category

+

{product.subCategory?.category?.name || "—"}

+
+
+

Sub Category

+

{product.subCategory?.name || product.subCategory?.id || "—"}

+
+
+
+ +
+
+ + +
+

Official Name

+

{product.name || "—"}

+
+ +
+ + +
+ + {product.isPreOrder ? ( +
+

Pre-order Day

+

{product.preOrderDay || "—"}

+
+ ) : null} + +
+

Description

+

{product.description || "—"}

+
+ + {features.length ? ( +
+

Key Features

+
+ {features.map((feature) => ( + + {feature} + + ))} +
+
+ ) : null} + + {keywords.length ? ( +
+

Keywords

+
+ {keywords.map((keyword) => ( + + {keyword} + + ))} +
+
+ ) : null} +
+ +
+
+ + {allImages.length ? ( +
+ {allImages.map((imageId, index) => { + const src = imageUrl(imageId); + if (!src) return null; + return ( + // eslint-disable-next-line @next/next/no-img-element + {`${product.name + ); + })} +
+ ) : ( +
+ Tidak ada gambar +
+ )} +
+ + {product.seller ? ( +
+ +
+ {imageUrl(product.seller.imageId) ? ( + // eslint-disable-next-line @next/next/no-img-element + {product.seller.name + ) : ( +
+ storefront +
+ )} +
+

{product.seller.name || "-"}

+

ID: {product.seller.id || "-"}

+
+
+
+ ) : null} +
+
+ +
+ + {models.length ? ( +
+ {models.map((model, index) => ( +
+
+
+

{model.name || `Model ${index + 1}`}

+

SKU: {model.sku || "-"}

+
+
+ +
+ {(Array.isArray(model.productMeasurements) ? model.productMeasurements : []).map((measurement, measurementIndex) => ( +
+ + + + + {measurement.isConfigurePromotionPrice ? ( + + ) : null} + {Array.isArray(measurement.warehouses) && measurement.warehouses.length ? ( +
+

Warehouses

+ {measurement.warehouses.map((warehouse, warehouseIndex) => ( +
+ + {[warehouse.city, warehouse.province, warehouse.country].filter(Boolean).join(", ") || "-"} + + {warehouse.stock ?? 0} unit +
+ ))} +
+ ) : null} +
+ ))} +
+
+ ))} +
+ ) : ( +
Tidak ada model produk
+ )} +
+ +
+
+ + {productInfos.length || categoryInfos.length ? ( +
+ {productInfos.length ? ( +
+

Product Information

+
+ {productInfos.map((item) => ( + + ))} +
+
+ ) : null} + + {categoryInfos.length ? ( +
+

Category Information

+
+ {categoryInfos.map((item) => ( + + ))} +
+
+ ) : null} +
+ ) : ( +
+ Tidak ada informasi tambahan +
+ )} +
+ +
+
+ +
+ + + + +
+
+ +
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/src/app/admin/products/page.tsx b/src/app/admin/products/page.tsx new file mode 100644 index 0000000..2f6ff4a --- /dev/null +++ b/src/app/admin/products/page.tsx @@ -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 ( +
+
+
+
+ {icon} +
+
+

{title}

+

{message}

+
+
+ +
+

Product

+

{product.name}

+

ID: {product.id}

+
+ +
+ + +
+
+
+ ); +} + +export default function AdminProductsPage() { + const searchParams = useSearchParams(); + const tab = searchParams.get("tab"); + const isDeletedTab = tab === "deleted"; + + const [rows, setRows] = useState([]); + 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(null); + const [restoreTarget, setRestoreTarget] = useState(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 ( +
+
+
+ +

+ {isDeletedTab ? "Deleted Products" : "All Products"} +

+

+ {isDeletedTab + ? "Produk yang sudah dihapus dan dapat dipulihkan kembali." + : "Monitoring seluruh katalog produk yang tersedia di platform."} +

+
+
+

Total Item

+

{loading ? "—" : totalItem}

+
+
+ +
+
+
+ {isDeletedTab ? "Deleted" : "All Product"} +
+

+ {isDeletedTab ? "Restore only" : "Detail and delete"} +

+
+ + {loading ? ( +
+ progress_activity +

Memuat data...

+
+ ) : error ? ( +
+ error +

{error}

+
+ ) : rows.length === 0 ? ( +
+ inventory_2 +

Tidak ada produk ditemukan

+
+ ) : ( +
+ + + + {["Product", "Market", "Price", "Stock", "Actions"].map((header) => ( + + ))} + + + + {rows.map((product) => ( + + + + + + + + ))} + +
+ {header} +
+
+

+ {product.name} +

+

ID: {product.id.slice(0, 8)}

+
+
+ + {product.market || "—"} + + + {formatPrice(product.minPrice, product.maxPrice)} + + + {product.totalStock ?? 0} + + +
+ {!isDeletedTab ? ( + + Detail + + ) : null} + + {isDeletedTab ? ( + + ) : ( + + )} +
+
+
+ )} + +
+

+ Total Item: {totalItem} +

+
+ + + / {Math.max(totalPage, 1)} + +
+
+
+ + {deleteTarget ? ( + 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 ? ( + 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} +
+ ); +} diff --git a/src/app/admin/review/[productId]/page.tsx b/src/app/admin/review/[productId]/page.tsx index 9ae2896..087281a 100644 --- a/src/app/admin/review/[productId]/page.tsx +++ b/src/app/admin/review/[productId]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useParams, useRouter } from "next/navigation"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; 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() { const params = useParams<{ productId: string }>(); 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 const [product, setProduct] = useState(null); // updated (review) @@ -352,16 +355,20 @@ export default function AdminReviewDetailPage() { return ( <> - {rejectModal} + {!isReadonly ? rejectModal : null}
{/* Header */}

{product.name}

@@ -421,46 +428,48 @@ export default function AdminReviewDetailPage() { )} {/* Action bar */} -
- {actionSuccess && ( -
- check_circle - {actionSuccess} Mengalihkan... -
- )} - {actionError && !showRejectModal && ( -
- error - {actionError} -
- )} - {acting && ( -

- progress_activity - Memproses review... -

- )} - {!actionSuccess && ( -
- - -
- )} -
+ {!isReadonly ? ( +
+ {actionSuccess && ( +
+ check_circle + {actionSuccess} Mengalihkan... +
+ )} + {actionError && !showRejectModal && ( +
+ error + {actionError} +
+ )} + {acting && ( +

+ progress_activity + Memproses review... +

+ )} + {!actionSuccess && ( +
+ + +
+ )} +
+ ) : null}
); diff --git a/src/app/api/admin/products/[productId]/route.ts b/src/app/api/admin/products/[productId]/route.ts new file mode 100644 index 0000000..c3a7cf6 --- /dev/null +++ b/src/app/api/admin/products/[productId]/route.ts @@ -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 }); +} diff --git a/src/app/api/admin/products/route.ts b/src/app/api/admin/products/route.ts new file mode 100644 index 0000000..3147727 --- /dev/null +++ b/src/app/api/admin/products/route.ts @@ -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 }); +} diff --git a/src/app/api/products/[productId]/route.ts b/src/app/api/products/[productId]/route.ts index 51563e6..f835618 100644 --- a/src/app/api/products/[productId]/route.ts +++ b/src/app/api/products/[productId]/route.ts @@ -38,8 +38,25 @@ export async function PUT( try { const token = normalizeBearerToken(req.headers.get("x-auth-token") || ""); const { productId } = await context.params; - const body = await req.json(); 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 ? `${API_URL}/api/v1.0/product/draft/${productId}` diff --git a/src/app/api/products/route.ts b/src/app/api/products/route.ts index c3d9158..fb24e05 100644 --- a/src/app/api/products/route.ts +++ b/src/app/api/products/route.ts @@ -13,6 +13,7 @@ export async function GET(req: NextRequest) { "local-market": "/api/v1.0/seller/local/product", "out-of-stock": "/api/v1.0/seller/outofstock/product", rejected: "/api/v1.0/seller/reject/product", + deleted: "/api/v1.0/seller/deleted/product", }; const endpoint = endpointMap[tab || ""] || "/api/v1.0/seller/product"; diff --git a/src/components/admin-product-submenu-nav.tsx b/src/components/admin-product-submenu-nav.tsx new file mode 100644 index 0000000..50010b9 --- /dev/null +++ b/src/components/admin-product-submenu-nav.tsx @@ -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 ( +
+ {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 ( + + {submenu.label} + + ); + })} +
+ ); +} diff --git a/src/components/product-submenu-nav.tsx b/src/components/product-submenu-nav.tsx index 70dab17..e98d031 100644 --- a/src/components/product-submenu-nav.tsx +++ b/src/components/product-submenu-nav.tsx @@ -12,6 +12,7 @@ const productSubmenu = [ { label: "Local Market", href: "/products?tab=local-market" }, { label: "Out Of Stock", href: "/products?tab=out-of-stock" }, { label: "Rejected", href: "/products?tab=rejected" }, + { label: "Deleted", href: "/products?tab=deleted" }, ]; function ProductSubmenuNavInner() { diff --git a/src/lib/translations/en.ts b/src/lib/translations/en.ts index 007a393..0e67a0f 100644 --- a/src/lib/translations/en.ts +++ b/src/lib/translations/en.ts @@ -358,6 +358,8 @@ export const en = { empty: "No products found.", edit: "Edit", detail: "Detail", + restore: "Restore", + unpublish: "Unpublish", table: { product: "Product", price: "Price", @@ -377,6 +379,16 @@ export const en = { deleting: "Deleting...", 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: { allProduct: "All Product", draft: "Draft", @@ -385,6 +397,7 @@ export const en = { localMarket: "Local Market", outOfStock: "Out of Stock", rejected: "Rejected", + deleted: "Deleted", }, }, productNew: { diff --git a/src/lib/translations/id.ts b/src/lib/translations/id.ts index d0d0a4f..2814d74 100644 --- a/src/lib/translations/id.ts +++ b/src/lib/translations/id.ts @@ -359,6 +359,8 @@ export const id = { empty: "Tidak ada produk ditemukan.", edit: "Edit", detail: "Detail", + restore: "Restore", + unpublish: "Unpublish", table: { product: "Produk", price: "Harga", @@ -378,6 +380,16 @@ export const id = { deleting: "Menghapus...", 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: { allProduct: "Semua Produk", draft: "Draft", @@ -386,6 +398,7 @@ export const id = { localMarket: "Pasar Lokal", outOfStock: "Habis Stok", rejected: "Ditolak", + deleted: "Deleted", }, }, productNew: {