Add seller deleted/unpublish flow and admin product management
This commit is contained in:
@ -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 ? (
|
||||||
|
!isDeletedTab ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/products/${product.id}/edit${activeTab === "Draft" ? "?draft=1" : ""}`}
|
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"
|
className="rounded-lg bg-primary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
{p.edit}
|
{p.edit}
|
||||||
</Link>
|
</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,6 +526,25 @@ function ProductsPageInner() {
|
|||||||
>
|
>
|
||||||
{p.detail}
|
{p.detail}
|
||||||
</Link>
|
</Link>
|
||||||
|
{isDeletedTab ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRestore(product.id)}
|
||||||
|
className="rounded-lg bg-tertiary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-tertiary/90"
|
||||||
|
>
|
||||||
|
{p.restore}
|
||||||
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDeleteTarget(product)}
|
onClick={() => setDeleteTarget(product)}
|
||||||
@ -465,6 +553,8 @@ function ProductsPageInner() {
|
|||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-base">delete</span>
|
<span className="material-symbols-outlined text-base">delete</span>
|
||||||
</button>
|
</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>
|
||||||
|
|||||||
@ -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,8 +39,8 @@ 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 (
|
||||||
|
<div key={item.href}>
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
|
||||||
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
|
||||||
@ -49,6 +51,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
<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>
|
||||||
|
|||||||
606
src/app/admin/products/[productId]/page.tsx
Normal file
606
src/app/admin/products/[productId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
394
src/app/admin/products/page.tsx
Normal file
394
src/app/admin/products/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,6 +428,7 @@ export default function AdminReviewDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action bar */}
|
{/* Action bar */}
|
||||||
|
{!isReadonly ? (
|
||||||
<div className="flex flex-col items-end gap-2 pt-2">
|
<div className="flex flex-col items-end gap-2 pt-2">
|
||||||
{actionSuccess && (
|
{actionSuccess && (
|
||||||
<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">
|
<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">
|
||||||
@ -461,6 +469,7 @@ export default function AdminReviewDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
50
src/app/api/admin/products/[productId]/route.ts
Normal file
50
src/app/api/admin/products/[productId]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
22
src/app/api/admin/products/route.ts
Normal file
22
src/app/api/admin/products/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
@ -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}`
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
47
src/components/admin-product-submenu-nav.tsx
Normal file
47
src/components/admin-product-submenu-nav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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() {
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user