Update seller product actions and stock price flow

This commit is contained in:
2026-05-12 12:25:28 +07:00
parent c3aa2e78d2
commit 458ec4ca7e
8 changed files with 694 additions and 62 deletions

View File

@ -2,7 +2,7 @@
import Image from "next/image";
import Link from "next/link";
import { Suspense, useEffect, useState } from "react";
import { Suspense, useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useLanguage } from "@/lib/i18n-context";
@ -32,6 +32,46 @@ interface ProductRow {
type ProductState = "UNPUBLISHED" | "DELETED_BY_SELLER" | "DELETED_BY_ADMIN" | string;
interface ProductWarehouseRef {
id?: string | number | null;
warehouseId?: string | number | null;
stock?: string | number | null;
}
interface ProductMeasurementRef {
id?: string | number | null;
productMeasurementId?: string | number | null;
price?: string | number | null;
warehouses?: ProductWarehouseRef[];
}
interface ProductModelRef {
id?: string | number | null;
productModelId?: string | number | null;
isMeasurement?: boolean | null;
price?: string | number | null;
warehouses?: ProductWarehouseRef[];
productMeasurements?: ProductMeasurementRef[];
}
interface ProductDetailRef {
productModels?: ProductModelRef[];
}
interface StockPriceTarget {
product: ProductRow;
currentPrice: number;
currentStock: number;
nextPrice: string;
nextStock: string;
productModelId: string;
productMeasurementId: string;
warehouseId: string;
loading: boolean;
submitting: boolean;
error: string;
}
function getToken() {
if (typeof window === "undefined") {
return "";
@ -60,6 +100,83 @@ function marketClasses(market: string) {
: "bg-tertiary-fixed text-on-tertiary-fixed";
}
function toNumber(value: string | number | null | undefined) {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
}
function toId(value: string | number | null | undefined) {
if (value === null || value === undefined) return "";
return String(value);
}
function formatCurrencyValue(value: number) {
return `Rp ${new Intl.NumberFormat("id-ID").format(value)}`;
}
function resolveStockPriceFields(product: ProductDetailRef) {
const models = Array.isArray(product.productModels) ? product.productModels : [];
const model = models[0];
if (!model) {
return null;
}
const productModelId = toId(model.id ?? model.productModelId);
const modelWarehouses = Array.isArray(model.warehouses) ? model.warehouses : [];
const modelWarehouse = modelWarehouses[0];
const modelWarehouseId = toId(modelWarehouse?.id ?? modelWarehouse?.warehouseId);
if (!productModelId || !modelWarehouseId) {
return null;
}
if (model.isMeasurement === false) {
return {
productModelId,
productMeasurementId: "",
warehouseId: modelWarehouseId,
currentPrice: toNumber(model.price),
currentStock: toNumber(modelWarehouse?.stock),
};
}
const measurements = Array.isArray(model.productMeasurements)
? model.productMeasurements
: [];
const measurement = measurements[0];
if (!measurement) {
return {
productModelId,
productMeasurementId: "",
warehouseId: modelWarehouseId,
currentPrice: toNumber(model.price),
currentStock: toNumber(modelWarehouse?.stock),
};
}
const measurementWarehouses = Array.isArray(measurement.warehouses)
? measurement.warehouses
: [];
const warehouse = measurementWarehouses[0] || modelWarehouse;
const warehouseId = toId(warehouse?.id ?? warehouse?.warehouseId);
if (!warehouseId) {
return null;
}
return {
productModelId,
productMeasurementId: toId(
measurement.id ?? measurement.productMeasurementId
),
warehouseId,
currentPrice: toNumber(measurement.price ?? model.price),
currentStock: toNumber(warehouse?.stock),
};
}
function tabFromQuery(tab: string | null): TabLabel {
switch (tab) {
case "draft":
@ -163,6 +280,134 @@ function ConfirmActionModal({
);
}
function StockPriceModal({
state,
d,
onCancel,
onPriceChange,
onStockChange,
onSubmit,
}: {
state: StockPriceTarget;
d: {
title: string;
message: string;
productLabel: string;
currentPrice: string;
currentStock: string;
newPrice: string;
newStock: string;
cancel: string;
confirm: string;
processing: string;
};
onCancel: () => void;
onPriceChange: (value: string) => void;
onStockChange: (value: string) => void;
onSubmit: () => void;
}) {
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-lg rounded-2xl border border-outline-variant/10 bg-surface-container-lowest p-8 shadow-2xl">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-secondary-container text-on-secondary-container">
<span className="material-symbols-outlined text-2xl">price_change</span>
</div>
<div>
<h2 className="text-lg font-black text-on-surface font-headline">{d.title}</h2>
<p className="mt-1 text-sm text-on-surface-variant">{d.message}</p>
</div>
</div>
<div className="mt-6 rounded-xl bg-surface-container-low p-4">
<p className="mb-1 text-[10px] font-black uppercase tracking-widest text-outline">{d.productLabel}</p>
<p className="text-sm font-bold text-on-surface">{state.product.name}</p>
<p className="mt-0.5 text-xs text-outline">ID: {state.product.id}</p>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-outline-variant/15 bg-surface-container-low p-4">
<p className="text-[10px] font-black uppercase tracking-widest text-outline">{d.currentPrice}</p>
<p className="mt-2 text-lg font-black text-on-surface">
{state.loading ? d.processing : formatCurrencyValue(state.currentPrice)}
</p>
</div>
<div className="rounded-xl border border-outline-variant/15 bg-surface-container-low p-4">
<p className="text-[10px] font-black uppercase tracking-widest text-outline">{d.currentStock}</p>
<p className="mt-2 text-lg font-black text-on-surface">
{state.loading ? d.processing : state.currentStock}
</p>
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-2 block text-xs font-black uppercase tracking-[0.18em] text-outline">
{d.newPrice}
</span>
<input
type="number"
min="0"
step="0.01"
value={state.nextPrice}
onChange={(event) => onPriceChange(event.target.value)}
disabled={state.loading || state.submitting}
className="w-full rounded-xl border border-outline-variant/20 bg-surface px-4 py-3 text-sm font-semibold text-on-surface outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-black uppercase tracking-[0.18em] text-outline">
{d.newStock}
</span>
<input
type="number"
min="0"
step="1"
value={state.nextStock}
onChange={(event) => onStockChange(event.target.value)}
disabled={state.loading || state.submitting}
className="w-full rounded-xl border border-outline-variant/20 bg-surface px-4 py-3 text-sm font-semibold text-on-surface outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
</div>
{state.error ? (
<div className="mt-4 rounded-xl border border-error/20 bg-error-container px-4 py-3 text-sm font-semibold text-on-error-container">
{state.error}
</div>
) : null}
<div className="mt-6 flex gap-3">
<button
type="button"
onClick={onCancel}
disabled={state.submitting}
className="flex-1 rounded-xl border border-outline-variant/30 px-4 py-3 text-sm font-black text-on-surface transition-colors hover:bg-surface-container-low disabled:opacity-50"
>
{d.cancel}
</button>
<button
type="button"
onClick={onSubmit}
disabled={state.loading || state.submitting}
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-primary px-4 py-3 text-sm font-black text-white transition-colors hover:bg-primary/90 disabled:opacity-60"
>
{state.submitting ? (
<>
<span className="material-symbols-outlined animate-spin text-base">progress_activity</span>
{d.processing}
</>
) : (
d.confirm
)}
</button>
</div>
</div>
</div>
);
}
function ProductsPageInner() {
const { t } = useLanguage();
const p = t.dashboard.products;
@ -188,12 +433,42 @@ function ProductsPageInner() {
const [unpublishing, setUnpublishing] = useState(false);
const [publishingId, setPublishingId] = useState<string | null>(null);
const [restoringId, setRestoringId] = useState<string | null>(null);
const [stockPriceTarget, setStockPriceTarget] = useState<StockPriceTarget | null>(null);
const [openActionMenuId, setOpenActionMenuId] = useState<string | null>(null);
const actionMenuRef = useRef<HTMLDivElement | null>(null);
// Reset to page 1 when tab changes
useEffect(() => {
setPage(1);
}, [tab]);
useEffect(() => {
setOpenActionMenuId(null);
}, [tab, page]);
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!actionMenuRef.current) return;
if (!actionMenuRef.current.contains(event.target as Node)) {
setOpenActionMenuId(null);
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpenActionMenuId(null);
}
}
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleEscape);
};
}, []);
useEffect(() => {
async function loadProducts() {
setLoading(true);
@ -232,7 +507,19 @@ function ProductsPageInner() {
setDeleting(true);
try {
const isDraft = tab === "draft";
const url = `/api/products/${deleteTarget.id}${isDraft ? "?draft=1" : ""}`;
const isInReview = tab === "in-review";
const searchParams = new URLSearchParams();
if (isDraft) {
searchParams.set("draft", "1");
}
if (isInReview) {
searchParams.set("review", "1");
}
const query = searchParams.toString();
const url = `/api/products/${deleteTarget.id}${query ? `?${query}` : ""}`;
await fetch(url, { method: "DELETE", headers: { "x-auth-token": getToken() } });
setDeleteTarget(null);
window.location.reload();
@ -267,6 +554,7 @@ function ProductsPageInner() {
}
async function handleRestore(productId: string) {
setOpenActionMenuId(null);
setRestoringId(productId);
try {
const res = await fetch(`/api/products/${productId}?action=restore`, {
@ -288,6 +576,7 @@ function ProductsPageInner() {
}
async function handlePublish(productId: string) {
setOpenActionMenuId(null);
setPublishingId(productId);
try {
const res = await fetch(`/api/products/${productId}?action=publish`, {
@ -309,6 +598,143 @@ function ProductsPageInner() {
}
}
async function openStockPriceModal(product: ProductRow) {
setOpenActionMenuId(null);
const isDraft = tab === "draft";
const isInReview = tab === "in-review";
const requestParams = new URLSearchParams();
if (isDraft) requestParams.set("draft", "1");
if (isInReview) requestParams.set("review", "1");
const query = requestParams.toString();
setStockPriceTarget({
product,
currentPrice: product.minPrice || 0,
currentStock: product.totalStock || 0,
nextPrice: String(product.minPrice || 0),
nextStock: String(product.totalStock || 0),
productModelId: "",
productMeasurementId: "",
warehouseId: "",
loading: true,
submitting: false,
error: "",
});
try {
const res = await fetch(
`/api/products/${product.id}${query ? `?${query}` : ""}`,
{ headers: { "x-auth-token": getToken() } }
);
const result = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(result?.responseDesc || p.stockPriceDialog.loadError);
}
const resolved = resolveStockPriceFields(result?.data || result);
if (!resolved) {
throw new Error(p.stockPriceDialog.targetError);
}
setStockPriceTarget((current) =>
current && current.product.id === product.id
? {
...current,
...resolved,
nextPrice: String(resolved.currentPrice),
nextStock: String(resolved.currentStock),
loading: false,
}
: current
);
} catch (err) {
setStockPriceTarget((current) =>
current && current.product.id === product.id
? {
...current,
loading: false,
error:
err instanceof Error
? err.message
: p.stockPriceDialog.loadError,
}
: current
);
}
}
async function handleSubmitStockPrice() {
if (!stockPriceTarget) return;
const price = Number(stockPriceTarget.nextPrice);
const stock = Number(stockPriceTarget.nextStock);
if (!Number.isFinite(price) || price < 0 || !Number.isFinite(stock) || stock < 0) {
setStockPriceTarget((current) =>
current
? { ...current, error: p.stockPriceDialog.invalidValueError }
: current
);
return;
}
if (
!stockPriceTarget.productModelId ||
!stockPriceTarget.warehouseId
) {
setStockPriceTarget((current) =>
current
? { ...current, error: p.stockPriceDialog.targetError }
: current
);
return;
}
setStockPriceTarget((current) =>
current ? { ...current, submitting: true, error: "" } : current
);
try {
const res = await fetch("/api/products/stock-price", {
method: "PUT",
headers: {
"Content-Type": "application/json",
"x-auth-token": getToken(),
},
body: JSON.stringify({
productModelId: stockPriceTarget.productModelId,
productMeasurementId: stockPriceTarget.productMeasurementId || null,
warehouseId: stockPriceTarget.warehouseId,
price,
stock,
}),
});
const result = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(result?.responseDesc || p.stockPriceDialog.updateError);
}
setStockPriceTarget(null);
window.location.reload();
} catch (err) {
setStockPriceTarget((current) =>
current
? {
...current,
submitting: false,
error:
err instanceof Error
? err.message
: p.stockPriceDialog.updateError,
}
: current
);
}
}
function getProductState(product: ProductRow): ProductState {
return (product.state || product.status || product.reviewStatus || "").toUpperCase();
}
@ -548,76 +974,188 @@ function ProductsPageInner() {
</span>
</td>
<td className="px-6 py-4 text-center">
<div className="flex items-center justify-center gap-3">
<div className="flex items-center justify-center">
{isDeletedTab ? (
productState === "DELETED_BY_ADMIN" ? (
<span className="rounded-full bg-error-container px-3 py-1 text-[10px] font-bold text-on-error-container">
{p.deletedByAdmin}
</span>
) : (
<button
type="button"
onClick={() => handleRestore(product.id)}
disabled={restoringId === product.id}
className="rounded-lg bg-tertiary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-tertiary/90 disabled:opacity-60"
>
{restoringId === product.id ? p.publishing : p.restore}
</button>
)
) : productState === "UNPUBLISHED" ? (
<button
type="button"
onClick={() => handlePublish(product.id)}
disabled={publishingId === product.id}
className="rounded-lg bg-tertiary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-tertiary/90 disabled:opacity-60"
>
{publishingId === product.id ? p.publishing : p.publish}
</button>
) : productState === "DELETED_BY_SELLER" ? (
<button
type="button"
onClick={() => handleRestore(product.id)}
disabled={restoringId === product.id}
className="rounded-lg bg-tertiary px-3 py-1 text-[10px] font-bold text-white transition-colors hover:bg-tertiary/90 disabled:opacity-60"
>
{restoringId === product.id ? p.publishing : p.restore}
</button>
) : (
<>
{!isInReviewTab ? (
!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" : ""}`}
className="text-[10px] font-bold text-primary transition-colors hover:underline"
>
{p.detail}
</Link>
{canUnpublish ? (
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
<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"
onClick={() =>
setOpenActionMenuId((current) =>
current === product.id ? null : product.id
)
}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-outline-variant/20 bg-surface-container-low text-on-surface transition-colors hover:border-primary/30 hover:text-primary"
aria-label={`Open actions for ${product.name}`}
aria-expanded={openActionMenuId === product.id}
>
{p.unpublish}
<span className="material-symbols-outlined text-xl">more_vert</span>
</button>
) : null}
{openActionMenuId === product.id ? (
<div className="absolute right-0 top-11 z-20 w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl">
<button
type="button"
onClick={() => handleRestore(product.id)}
disabled={restoringId === product.id}
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low disabled:opacity-60"
>
<span className="material-symbols-outlined text-lg text-tertiary">settings_backup_restore</span>
{restoringId === product.id ? p.publishing : p.restore}
</button>
</div>
) : null}
</div>
)
) : productState === "UNPUBLISHED" ? (
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : 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"
onClick={() =>
setOpenActionMenuId((current) =>
current === product.id ? null : product.id
)
}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-outline-variant/20 bg-surface-container-low text-on-surface transition-colors hover:border-primary/30 hover:text-primary"
aria-label={`Open actions for ${product.name}`}
aria-expanded={openActionMenuId === product.id}
>
<span className="material-symbols-outlined text-base">delete</span>
<span className="material-symbols-outlined text-xl">more_vert</span>
</button>
</>
{openActionMenuId === product.id ? (
<div className="absolute right-0 top-11 z-20 w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl">
<button
type="button"
onClick={() => handlePublish(product.id)}
disabled={publishingId === product.id}
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low disabled:opacity-60"
>
<span className="material-symbols-outlined text-lg text-tertiary">publish</span>
{publishingId === product.id ? p.publishing : p.publish}
</button>
<button
type="button"
onClick={() => openStockPriceModal(product)}
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low"
>
<span className="material-symbols-outlined text-lg text-secondary">price_change</span>
{p.editStockPrice}
</button>
</div>
) : null}
</div>
) : productState === "DELETED_BY_SELLER" ? (
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
<button
type="button"
onClick={() =>
setOpenActionMenuId((current) =>
current === product.id ? null : product.id
)
}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-outline-variant/20 bg-surface-container-low text-on-surface transition-colors hover:border-primary/30 hover:text-primary"
aria-label={`Open actions for ${product.name}`}
aria-expanded={openActionMenuId === product.id}
>
<span className="material-symbols-outlined text-xl">more_vert</span>
</button>
{openActionMenuId === product.id ? (
<div className="absolute right-0 top-11 z-20 w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl">
<button
type="button"
onClick={() => handleRestore(product.id)}
disabled={restoringId === product.id}
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low disabled:opacity-60"
>
<span className="material-symbols-outlined text-lg text-tertiary">settings_backup_restore</span>
{restoringId === product.id ? p.publishing : p.restore}
</button>
<button
type="button"
onClick={() => openStockPriceModal(product)}
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low"
>
<span className="material-symbols-outlined text-lg text-secondary">price_change</span>
{p.editStockPrice}
</button>
</div>
) : null}
</div>
) : (
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
<button
type="button"
onClick={() =>
setOpenActionMenuId((current) =>
current === product.id ? null : product.id
)
}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-outline-variant/20 bg-surface-container-low text-on-surface transition-colors hover:border-primary/30 hover:text-primary"
aria-label={`Open actions for ${product.name}`}
aria-expanded={openActionMenuId === product.id}
>
<span className="material-symbols-outlined text-xl">more_vert</span>
</button>
{openActionMenuId === product.id ? (
<div className="absolute right-0 top-11 z-20 w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl">
{!isInReviewTab ? (
!isDeletedTab ? (
<Link
href={`/products/${product.id}/edit${activeTab === "Draft" ? "?draft=1" : ""}`}
onClick={() => setOpenActionMenuId(null)}
className="flex items-center gap-3 px-4 py-3 text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low"
>
<span className="material-symbols-outlined text-lg text-primary">edit</span>
{p.edit}
</Link>
) : null
) : null}
<Link
href={`/products/${product.id}/detail${activeTab === "Draft" ? "?draft=1" : isInReviewTab ? "?review=1" : ""}`}
onClick={() => setOpenActionMenuId(null)}
className="flex items-center gap-3 px-4 py-3 text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low"
>
<span className="material-symbols-outlined text-lg text-primary">visibility</span>
{p.detail}
</Link>
<button
type="button"
onClick={() => openStockPriceModal(product)}
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low"
>
<span className="material-symbols-outlined text-lg text-secondary">price_change</span>
{p.editStockPrice}
</button>
{canUnpublish ? (
<button
type="button"
onClick={() => {
setOpenActionMenuId(null);
setUnpublishTarget(product);
}}
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold text-on-surface transition-colors hover:bg-surface-container-low"
>
<span className="material-symbols-outlined text-lg text-secondary">visibility_off</span>
{p.unpublish}
</button>
) : null}
<button
type="button"
onClick={() => {
setOpenActionMenuId(null);
setDeleteTarget(product);
}}
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold text-error transition-colors hover:bg-error-container/40"
>
<span className="material-symbols-outlined text-lg">delete</span>
Delete
</button>
</div>
) : null}
</div>
)}
</div>
</td>
@ -683,6 +1221,29 @@ function ProductsPageInner() {
confirmToneClass="bg-secondary hover:bg-secondary/90"
/>
)}
{stockPriceTarget && (
<StockPriceModal
state={stockPriceTarget}
d={p.stockPriceDialog}
onCancel={() => {
if (!stockPriceTarget.submitting) {
setStockPriceTarget(null);
}
}}
onPriceChange={(value) =>
setStockPriceTarget((current) =>
current ? { ...current, nextPrice: value, error: "" } : current
)
}
onStockChange={(value) =>
setStockPriceTarget((current) =>
current ? { ...current, nextStock: value, error: "" } : current
)
}
onSubmit={handleSubmitStockPrice}
/>
)}
</div>
);
}

View File

@ -131,10 +131,13 @@ export async function DELETE(
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
const { productId } = await context.params;
const isDraft = req.nextUrl.searchParams.get("draft") === "1";
const isReview = req.nextUrl.searchParams.get("review") === "1";
const endpoint = isDraft
? `${API_URL}/api/v1.0/product/draft/${productId}`
: `${API_URL}/api/v1.0/product/${productId}`;
: isReview
? `${API_URL}/api/v1.0/seller/product/${productId}?state=REVIEW`
: `${API_URL}/api/v1.0/product/${productId}`;
const res = await fetch(endpoint, {
method: "DELETE",

View File

@ -4,7 +4,8 @@ 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.searchParams;
const tab = searchParams.get("tab");
const rawTab = searchParams.get("tab");
const tab = rawTab === "all" ? "" : rawTab;
const endpointMap: Record<string, string> = {
draft: "/api/v1.0/seller/draft/product",

View File

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { API_URL, makeHeaders } from "@/lib/api";
function normalizeBearerToken(rawToken: string) {
if (!rawToken) return "";
return rawToken.startsWith("Bearer ") ? rawToken : `Bearer ${rawToken}`;
}
export async function PUT(req: NextRequest) {
try {
const token = normalizeBearerToken(req.headers.get("x-auth-token") || "");
const body = await req.json();
const res = await fetch(`${API_URL}/api/v1.0/product/stock-price`, {
method: "PUT",
headers: makeHeaders(token),
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
} catch (error) {
return NextResponse.json(
{
responseDesc:
error instanceof Error ? error.message : "Unknown proxy error",
},
{ status: 500 }
);
}
}

View File

@ -12,7 +12,8 @@ const adminProductSubmenu = [
function AdminProductSubmenuNavInner() {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentTab = searchParams.get("tab") ?? "";
const rawTab = searchParams.get("tab") ?? "";
const currentTab = rawTab === "all" ? "" : rawTab;
if (pathname !== "/admin/products" && !pathname.startsWith("/admin/products/")) {
return null;

View File

@ -18,7 +18,8 @@ const productSubmenu = [
function ProductSubmenuNavInner() {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentTab = searchParams.get("tab") ?? "";
const rawTab = searchParams.get("tab") ?? "";
const currentTab = rawTab === "all" ? "" : rawTab;
if (pathname !== "/products" && !pathname.startsWith("/products/")) return null;

View File

@ -357,6 +357,7 @@ export const en = {
loading: "Loading products...",
empty: "No products found.",
edit: "Edit",
editStockPrice: "Edit Stock & Price",
detail: "Detail",
publish: "Publish",
publishing: "Processing...",
@ -391,6 +392,22 @@ export const en = {
processing: "Processing...",
errorGeneric: "Failed to unpublish product",
},
stockPriceDialog: {
title: "Edit Stock and Price",
message: "Update the stock and price for the selected product.",
productLabel: "Selected product",
currentPrice: "Current Price",
currentStock: "Current Stock",
newPrice: "New Price",
newStock: "New Stock",
cancel: "Cancel",
confirm: "Submit",
processing: "Processing...",
loadError: "Failed to load product details",
targetError: "Product model or warehouse identifier was not found",
invalidValueError: "Price and stock must be 0 or greater",
updateError: "Failed to update product stock and price",
},
restoreError: "Failed to restore product",
publishError: "Failed to publish product",
tabs: {

View File

@ -358,6 +358,7 @@ export const id = {
loading: "Memuat produk...",
empty: "Tidak ada produk ditemukan.",
edit: "Edit",
editStockPrice: "Edit Stok & Harga",
detail: "Detail",
publish: "Publish",
publishing: "Memproses...",
@ -392,6 +393,22 @@ export const id = {
processing: "Memproses...",
errorGeneric: "Gagal unpublish produk",
},
stockPriceDialog: {
title: "Edit Stok dan Harga",
message: "Perbarui stok dan harga produk yang dipilih.",
productLabel: "Produk terpilih",
currentPrice: "Harga Sekarang",
currentStock: "Stok Sekarang",
newPrice: "Harga Baru",
newStock: "Stok Baru",
cancel: "Batal",
confirm: "Submit",
processing: "Memproses...",
loadError: "Gagal memuat detail produk",
targetError: "Identitas model atau warehouse produk tidak ditemukan",
invalidValueError: "Harga dan stok harus bernilai 0 atau lebih",
updateError: "Gagal memperbarui stok dan harga produk",
},
restoreError: "Gagal restore produk",
publishError: "Gagal publish produk",
tabs: {