Add seller deleted/unpublish flow and admin product management
This commit is contained in:
@ -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 (
|
||||
<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="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="w-12 h-12 rounded-xl bg-error-container flex items-center justify-center flex-shrink-0">
|
||||
<span className="material-symbols-outlined text-error text-2xl">delete_forever</span>
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 ${iconToneClass}`}>
|
||||
<span className="material-symbols-outlined text-2xl">{icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-black text-on-surface font-headline">{d.title}</h2>
|
||||
@ -113,7 +132,7 @@ function DeleteConfirmModal({
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{d.cancel}
|
||||
@ -121,17 +140,17 @@ function DeleteConfirmModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={deleting}
|
||||
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"
|
||||
disabled={processing}
|
||||
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>
|
||||
{d.deleting}
|
||||
{processingLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-base">delete</span>
|
||||
<span className="material-symbols-outlined text-base">{icon}</span>
|
||||
{d.confirm}
|
||||
</>
|
||||
)}
|
||||
@ -149,6 +168,11 @@ function ProductsPageInner() {
|
||||
const tab = searchParams.get("tab");
|
||||
const activeTab = tabFromQuery(tab);
|
||||
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 [loading, setLoading] = useState(true);
|
||||
@ -157,7 +181,9 @@ function ProductsPageInner() {
|
||||
const [totalPage, setTotalPage] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ProductRow | null>(null);
|
||||
const [unpublishTarget, setUnpublishTarget] = useState<ProductRow | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [unpublishing, setUnpublishing] = useState(false);
|
||||
|
||||
// Reset to page 1 when tab changes
|
||||
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(
|
||||
(row) => row.market === "International"
|
||||
).length;
|
||||
@ -444,12 +511,14 @@ function ProductsPageInner() {
|
||||
<td className="px-6 py-4 text-center">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
{!isInReviewTab ? (
|
||||
<Link
|
||||
href={`/products/${product.id}/edit${activeTab === "Draft" ? "?draft=1" : ""}`}
|
||||
className="rounded-lg bg-primary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{p.edit}
|
||||
</Link>
|
||||
!isDeletedTab ? (
|
||||
<Link
|
||||
href={`/products/${product.id}/edit${activeTab === "Draft" ? "?draft=1" : ""}`}
|
||||
className="rounded-lg bg-primary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{p.edit}
|
||||
</Link>
|
||||
) : null
|
||||
) : null}
|
||||
<Link
|
||||
href={`/products/${product.id}/detail${activeTab === "Draft" ? "?draft=1" : isInReviewTab ? "?review=1" : ""}`}
|
||||
@ -457,14 +526,35 @@ function ProductsPageInner() {
|
||||
>
|
||||
{p.detail}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteTarget(product)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-error hover:bg-error-container transition-colors"
|
||||
title="Hapus"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">delete</span>
|
||||
</button>
|
||||
{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
|
||||
type="button"
|
||||
onClick={() => setDeleteTarget(product)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg text-error hover:bg-error-container transition-colors"
|
||||
title="Hapus"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">delete</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -505,12 +595,28 @@ function ProductsPageInner() {
|
||||
</section>
|
||||
|
||||
{deleteTarget && (
|
||||
<DeleteConfirmModal
|
||||
<ConfirmActionModal
|
||||
product={deleteTarget}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => 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 && (
|
||||
<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>
|
||||
|
||||
@ -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 (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center px-6 py-3 font-semibold transition-colors ${
|
||||
isActive
|
||||
? "text-primary font-bold border-r-4 border-primary bg-white"
|
||||
: "text-slate-500 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined mr-3 text-xl">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
<div key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`flex items-center px-6 py-3 font-semibold transition-colors ${
|
||||
isActive
|
||||
? "text-primary font-bold border-r-4 border-primary bg-white"
|
||||
: "text-slate-500 hover:bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined mr-3 text-xl">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
{item.hasSubmenu ? <AdminProductSubmenuNav /> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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";
|
||||
|
||||
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<any>(null); // updated (review)
|
||||
@ -352,16 +355,20 @@ export default function AdminReviewDetailPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{rejectModal}
|
||||
{!isReadonly ? rejectModal : null}
|
||||
|
||||
<div className="m-6 space-y-6 pb-10">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<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="text-primary">{isComparison ? "Review Update" : "Review Produk Baru"}</span>
|
||||
<span className="text-primary">
|
||||
{isReadonly ? "Product Detail" : isComparison ? "Review Update" : "Review Produk Baru"}
|
||||
</span>
|
||||
</nav>
|
||||
<h1 className="text-3xl font-black tracking-tight text-on-surface">{product.name}</h1>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
@ -421,46 +428,48 @@ export default function AdminReviewDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="flex flex-col items-end gap-2 pt-2">
|
||||
{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">
|
||||
<span className="material-symbols-outlined text-tertiary">check_circle</span>
|
||||
{actionSuccess} Mengalihkan...
|
||||
</div>
|
||||
)}
|
||||
{actionError && !showRejectModal && (
|
||||
<div className="w-full p-4 bg-error-container text-on-error-container rounded-xl flex items-center gap-3 font-semibold text-sm">
|
||||
<span className="material-symbols-outlined">error</span>
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
{acting && (
|
||||
<p className="text-xs text-slate-400 flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
|
||||
Memproses review...
|
||||
</p>
|
||||
)}
|
||||
{!actionSuccess && (
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => { setShowRejectModal(true); setActionError(""); }}
|
||||
disabled={acting}
|
||||
className="flex items-center gap-2 px-6 py-3.5 rounded-xl border-2 border-error text-error font-black text-sm uppercase tracking-[0.15em] hover:bg-error/5 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">block</span>
|
||||
{isComparison ? "Reject Update" : "Reject Product"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => submitReview("accept")}
|
||||
disabled={acting}
|
||||
className="flex items-center gap-2 bg-gradient-to-r from-primary to-primary-container text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.15em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all disabled:opacity-60 disabled:hover:translate-y-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">check_circle</span>
|
||||
{acting ? "Memproses..." : isComparison ? "Accept Update" : "Accept Product"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isReadonly ? (
|
||||
<div className="flex flex-col items-end gap-2 pt-2">
|
||||
{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">
|
||||
<span className="material-symbols-outlined text-tertiary">check_circle</span>
|
||||
{actionSuccess} Mengalihkan...
|
||||
</div>
|
||||
)}
|
||||
{actionError && !showRejectModal && (
|
||||
<div className="w-full p-4 bg-error-container text-on-error-container rounded-xl flex items-center gap-3 font-semibold text-sm">
|
||||
<span className="material-symbols-outlined">error</span>
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
{acting && (
|
||||
<p className="text-xs text-slate-400 flex items-center gap-1.5">
|
||||
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
|
||||
Memproses review...
|
||||
</p>
|
||||
)}
|
||||
{!actionSuccess && (
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => { setShowRejectModal(true); setActionError(""); }}
|
||||
disabled={acting}
|
||||
className="flex items-center gap-2 px-6 py-3.5 rounded-xl border-2 border-error text-error font-black text-sm uppercase tracking-[0.15em] hover:bg-error/5 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">block</span>
|
||||
{isComparison ? "Reject Update" : "Reject Product"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => submitReview("accept")}
|
||||
disabled={acting}
|
||||
className="flex items-center gap-2 bg-gradient-to-r from-primary to-primary-container text-white px-8 py-3.5 rounded-xl font-black text-sm uppercase tracking-[0.15em] shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all disabled:opacity-60 disabled:hover:translate-y-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">check_circle</span>
|
||||
{acting ? "Memproses..." : isComparison ? "Accept Update" : "Accept Product"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</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 {
|
||||
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}`
|
||||
|
||||
@ -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";
|
||||
|
||||
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: "Out Of Stock", href: "/products?tab=out-of-stock" },
|
||||
{ label: "Rejected", href: "/products?tab=rejected" },
|
||||
{ label: "Deleted", href: "/products?tab=deleted" },
|
||||
];
|
||||
|
||||
function ProductSubmenuNavInner() {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user