From 458ec4ca7e214fede3b5e7011fd1602e20f04660 Mon Sep 17 00:00:00 2001 From: Wira Irawan Date: Tue, 12 May 2026 12:25:28 +0700 Subject: [PATCH] Update seller product actions and stock price flow --- src/app/(dashboard)/products/page.tsx | 677 +++++++++++++++++-- src/app/api/products/[productId]/route.ts | 5 +- src/app/api/products/route.ts | 3 +- src/app/api/products/stock-price/route.ts | 31 + src/components/admin-product-submenu-nav.tsx | 3 +- src/components/product-submenu-nav.tsx | 3 +- src/lib/translations/en.ts | 17 + src/lib/translations/id.ts | 17 + 8 files changed, 694 insertions(+), 62 deletions(-) create mode 100644 src/app/api/products/stock-price/route.ts 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: {