diff --git a/src/app/(dashboard)/products/page.tsx b/src/app/(dashboard)/products/page.tsx
index c8c22d4..c59a75d 100644
--- a/src/app/(dashboard)/products/page.tsx
+++ b/src/app/(dashboard)/products/page.tsx
@@ -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 (
+
+
+
+
+
+ price_change
+
+
+
{d.title}
+
{d.message}
+
+
+
+
+
{d.productLabel}
+
{state.product.name}
+
ID: {state.product.id}
+
+
+
+
+
{d.currentPrice}
+
+ {state.loading ? d.processing : formatCurrencyValue(state.currentPrice)}
+
+
+
+
{d.currentStock}
+
+ {state.loading ? d.processing : state.currentStock}
+
+
+
+
+
+
+
+
+
+ {state.error ? (
+
+ {state.error}
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
+
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(null);
const [restoringId, setRestoringId] = useState(null);
+ const [stockPriceTarget, setStockPriceTarget] = useState(null);
+ const [openActionMenuId, setOpenActionMenuId] = useState(null);
+ const actionMenuRef = useRef(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() {
-
+
{isDeletedTab ? (
productState === "DELETED_BY_ADMIN" ? (
{p.deletedByAdmin}
) : (
-
- )
- ) : productState === "UNPUBLISHED" ? (
-
- ) : productState === "DELETED_BY_SELLER" ? (
-
- ) : (
- <>
- {!isInReviewTab ? (
- !isDeletedTab ? (
-
- {p.edit}
-
- ) : null
- ) : null}
-
- {p.detail}
-
- {canUnpublish ? (
+
- ) : null}
+ {openActionMenuId === product.id ? (
+
+
+
+ ) : null}
+
+ )
+ ) : productState === "UNPUBLISHED" ? (
+
- >
+ {openActionMenuId === product.id ? (
+
+
+
+
+ ) : null}
+
+ ) : productState === "DELETED_BY_SELLER" ? (
+
+
+ {openActionMenuId === product.id ? (
+
+
+
+
+ ) : null}
+
+ ) : (
+
+
+ {openActionMenuId === product.id ? (
+
+ {!isInReviewTab ? (
+ !isDeletedTab ? (
+ 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"
+ >
+ edit
+ {p.edit}
+
+ ) : null
+ ) : null}
+ 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"
+ >
+ visibility
+ {p.detail}
+
+
+ {canUnpublish ? (
+
+ ) : null}
+
+
+ ) : null}
+
)}
|
@@ -683,6 +1221,29 @@ function ProductsPageInner() {
confirmToneClass="bg-secondary hover:bg-secondary/90"
/>
)}
+
+ {stockPriceTarget && (
+ {
+ 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}
+ />
+ )}
);
}
diff --git a/src/app/api/products/[productId]/route.ts b/src/app/api/products/[productId]/route.ts
index 572e65e..5762d56 100644
--- a/src/app/api/products/[productId]/route.ts
+++ b/src/app/api/products/[productId]/route.ts
@@ -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",
diff --git a/src/app/api/products/route.ts b/src/app/api/products/route.ts
index fb24e05..5f9a352 100644
--- a/src/app/api/products/route.ts
+++ b/src/app/api/products/route.ts
@@ -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 = {
draft: "/api/v1.0/seller/draft/product",
diff --git a/src/app/api/products/stock-price/route.ts b/src/app/api/products/stock-price/route.ts
new file mode 100644
index 0000000..6a06d67
--- /dev/null
+++ b/src/app/api/products/stock-price/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/components/admin-product-submenu-nav.tsx b/src/components/admin-product-submenu-nav.tsx
index d31c2d0..74a068c 100644
--- a/src/components/admin-product-submenu-nav.tsx
+++ b/src/components/admin-product-submenu-nav.tsx
@@ -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;
diff --git a/src/components/product-submenu-nav.tsx b/src/components/product-submenu-nav.tsx
index e98d031..3b66057 100644
--- a/src/components/product-submenu-nav.tsx
+++ b/src/components/product-submenu-nav.tsx
@@ -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;
diff --git a/src/lib/translations/en.ts b/src/lib/translations/en.ts
index 6f3018f..2dc4f60 100644
--- a/src/lib/translations/en.ts
+++ b/src/lib/translations/en.ts
@@ -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: {
diff --git a/src/lib/translations/id.ts b/src/lib/translations/id.ts
index 65dce67..bf2adaa 100644
--- a/src/lib/translations/id.ts
+++ b/src/lib/translations/id.ts
@@ -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: {