From e9a0cd0b2d4be6a6e761c5c928c50897ac22d680 Mon Sep 17 00:00:00 2001 From: Wira Irawan Date: Tue, 19 May 2026 14:02:09 +0700 Subject: [PATCH] Update product review, measurement, and backend logging flows --- .../products/[productId]/detail/page.tsx | 109 +++- .../products/[productId]/edit/page.tsx | 144 ++++-- .../(dashboard)/products/new/pricing/page.tsx | 143 +++++- .../(dashboard)/products/new/review/page.tsx | 96 +++- src/app/(dashboard)/products/page.tsx | 474 +++++++++++++++--- src/app/admin/review/[productId]/page.tsx | 373 ++++++++++++-- src/components/admin-product-submenu-nav.tsx | 15 +- src/components/product-submenu-nav.tsx | 15 +- src/instrumentation.ts | 5 + src/lib/backend-fetch-logger.ts | 141 ++++++ src/lib/translations/en.ts | 14 + src/lib/translations/id.ts | 14 + src/lib/use-product-submit.ts | 92 ++-- 13 files changed, 1375 insertions(+), 260 deletions(-) create mode 100644 src/instrumentation.ts create mode 100644 src/lib/backend-fetch-logger.ts diff --git a/src/app/(dashboard)/products/[productId]/detail/page.tsx b/src/app/(dashboard)/products/[productId]/detail/page.tsx index d831450..04eae9e 100644 --- a/src/app/(dashboard)/products/[productId]/detail/page.tsx +++ b/src/app/(dashboard)/products/[productId]/detail/page.tsx @@ -12,6 +12,13 @@ interface ProductWarehouse { stock?: number; } +interface WarehouseLookup { + id: string; + name?: string; + address?: string; + city?: string; +} + interface ProductMeasurement { measurementType?: string; measurementValue?: string; @@ -44,6 +51,11 @@ interface ProductInfoItem { paramValue: string; } +interface CategoryOption { + id: string; + name: string; +} + interface ProductCategory { name?: string; } @@ -132,6 +144,8 @@ function ProductDetailPageInner() { const isDraft = searchParams.get("draft") === "1"; const isReview = searchParams.get("review") === "1"; const [product, setProduct] = useState(null); + const [resolvedMainCategoryName, setResolvedMainCategoryName] = useState(""); + const [warehouseMap, setWarehouseMap] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const errorLoadText = d.errorLoad; @@ -153,6 +167,95 @@ function ProductDetailPageInner() { .finally(() => setLoading(false)); }, [errorLoadText, params.productId, isDraft, isReview]); + useEffect(() => { + if (!product?.subCategory?.id || product.subCategory.category?.name) { + return; + } + + let cancelled = false; + + async function resolveMainCategory() { + try { + const token = getToken(); + const categoriesRes = await fetch("/api/products/categories?size=100", { + headers: { "x-auth-token": token }, + }); + const categoriesJson = await categoriesRes.json().catch(() => ({})); + const categories: CategoryOption[] = Array.isArray(categoriesJson?.rows) + ? categoriesJson.rows + : []; + + for (const category of categories) { + const subcategoriesRes = await fetch( + `/api/products/subcategories/${category.id}?size=100`, + { headers: { "x-auth-token": token } } + ); + const subcategoriesJson = await subcategoriesRes.json().catch(() => ({})); + const rows: CategoryOption[] = Array.isArray(subcategoriesJson?.rows) + ? subcategoriesJson.rows + : []; + if (rows.some((subCategory) => subCategory.id === product.subCategory?.id)) { + if (!cancelled) { + setResolvedMainCategoryName(category.name); + } + return; + } + } + + if (!cancelled) { + setResolvedMainCategoryName(""); + } + } catch { + if (!cancelled) { + setResolvedMainCategoryName(""); + } + } + } + + resolveMainCategory(); + + return () => { + cancelled = true; + }; + }, [product?.subCategory?.id, product?.subCategory?.category?.name]); + + useEffect(() => { + let cancelled = false; + + async function loadWarehouses() { + try { + const res = await fetch("/api/products/warehouses?size=100", { + headers: { "x-auth-token": getToken() }, + }); + const json = await res.json().catch(() => ({})); + const rows: WarehouseLookup[] = Array.isArray(json?.rows) + ? json.rows + : Array.isArray(json?.data) + ? json.data + : []; + const map: Record = {}; + for (const warehouse of rows) { + const location = warehouse.city?.trim(); + const primaryLabel = warehouse.name?.trim() || warehouse.address?.trim() || warehouse.id; + map[warehouse.id] = location ? `${primaryLabel} — ${location}` : primaryLabel; + } + if (!cancelled) { + setWarehouseMap(map); + } + } catch { + if (!cancelled) { + setWarehouseMap({}); + } + } + } + + loadWarehouses(); + + return () => { + cancelled = true; + }; + }, []); + if (loading) { return (
@@ -226,7 +329,7 @@ function ProductDetailPageInner() {

{d.mainCategory}

-

{product.subCategory?.category?.name || "—"}

+

{product.subCategory?.category?.name || resolvedMainCategoryName || "—"}

{d.subCategory}

@@ -376,7 +479,7 @@ function ProductDetailPageInner() { {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {m.warehouses.filter((w: any) => w.id).map((w: any, wi: number) => (
- {w.id?.slice(0, 8)}... + {warehouseMap[w.id] || w.name || `${w.id?.slice(0, 8)}...`} {w.stock ?? 0} unit
))} @@ -414,7 +517,7 @@ function ProductDetailPageInner() { .filter((w: ProductWarehouse) => w.id) .map((w: ProductWarehouse, wi: number) => (
- {w.id?.slice(0, 8)}... + {warehouseMap[w.id || ""] || `${w.id?.slice(0, 8)}...`} {w.stock ?? 0} unit
))} diff --git a/src/app/(dashboard)/products/[productId]/edit/page.tsx b/src/app/(dashboard)/products/[productId]/edit/page.tsx index 604f6c4..677c01b 100644 --- a/src/app/(dashboard)/products/[productId]/edit/page.tsx +++ b/src/app/(dashboard)/products/[productId]/edit/page.tsx @@ -74,6 +74,7 @@ interface EditModel { promotionCurrency: string; promotionStartDate: string; promotionEndDate: string; + hasMeasurements: boolean; warehouses: EditWarehouse[]; measurements: EditMeasurement[]; } @@ -145,6 +146,7 @@ interface ApiModel extends ApiMeasurement { sku?: string | number | null; imageId?: string | number | null; image?: string | number | null; + isMeasurement?: boolean | null; warehouses?: ApiWarehouse[]; productMeasurements?: ApiMeasurement[]; } @@ -324,6 +326,7 @@ function newModel(index: number): EditModel { promotionCurrency: "IDR", promotionStartDate: "", promotionEndDate: "", + hasMeasurements: false, warehouses: [{ id: "", stock: 0 }], measurements: [], }; @@ -357,6 +360,7 @@ function apiToEditState(data: ApiProduct): EditState { promotionCurrency: toStr(m.promotionCurrency) || toStr(m.currency) || "IDR", promotionStartDate: toStr(m.promotionStartDate), promotionEndDate: toStr(m.promotionEndDate), + hasMeasurements: m.isMeasurement === true || (Array.isArray(m.productMeasurements) && m.productMeasurements.length > 0), warehouses: Array.isArray(m.warehouses) && m.warehouses.length > 0 ? m.warehouses.map((w: ApiWarehouse) => ({ id: toStr(w.id), @@ -797,14 +801,26 @@ function ModelCard({ onChange({ ...model, [field]: value }); } + function toggleMeasurements(enabled: boolean) { + onChange({ + ...model, + hasMeasurements: enabled, + measurements: + enabled && model.measurements.length === 0 + ? [newMeasurement()] + : model.measurements, + }); + } + function updateWh(wi: number, field: "id" | "stock", value: string | number) { onChange({ ...model, warehouses: model.warehouses.map((w, i) => i === wi ? { ...w, [field]: value } : w) }); } - const hasMeasurements = model.measurements.length > 0; + const hasMeasurements = model.hasMeasurements; const inp = "w-full bg-surface-container-low border border-outline-variant/10 rounded-xl px-3 py-2.5 text-sm font-semibold focus:ring-2 focus:ring-primary/10 focus:outline-none"; const sel = "w-full bg-surface-container-low border border-outline-variant/10 rounded-xl px-3 py-2.5 text-xs font-bold focus:ring-2 focus:ring-primary/10 focus:outline-none"; + const disabledBlock = hasMeasurements ? "opacity-50 pointer-events-none" : ""; return (
@@ -812,7 +828,7 @@ function ModelCard({
{index + 1}
-
+
set("name", ev.target.value)} @@ -825,6 +841,27 @@ function ModelCard({ placeholder="SKU" className="bg-transparent border-none text-xs font-bold text-outline focus:ring-0 p-0 placeholder-outline/30 w-32" /> +
+
+
+

+ Product Measurement +

+

+ Gunakan measurement untuk harga, berat, dimensi, promo, dan stok. +

+
+ +
+
{total > 1 && ( @@ -845,16 +882,16 @@ function ModelCard({
{/* Pricing & Specs */} -
+
{/* Price row */}
- set("price", ev.target.value)} placeholder="0" type="number" min="0" className={inp} /> + set("price", ev.target.value)} placeholder="0" type="number" min="0" className={inp} disabled={hasMeasurements} />
- set("currency", ev.target.value)} className={sel} disabled={hasMeasurements}> {WORLD_CURRENCIES.map((c) => )}
@@ -864,10 +901,10 @@ function ModelCard({
- set("weightType", ev.target.value)} className={sel} disabled={hasMeasurements}> {WEIGHT_TYPES.map((w) => )} - set("weight", ev.target.value)} placeholder="0" type="number" min="0" className={inp} /> + set("weight", ev.target.value)} placeholder="0" type="number" min="0" className={inp} disabled={hasMeasurements} />
@@ -875,28 +912,38 @@ function ModelCard({
- set("dimensionType", ev.target.value)} className={sel} disabled={hasMeasurements}> {DIMENSION_TYPES.map((d) => )} - set("length", ev.target.value)} placeholder="L" type="number" min="0" className={inp + " text-center"} /> - set("width", ev.target.value)} placeholder="W" type="number" min="0" className={inp + " text-center"} /> - set("height", ev.target.value)} placeholder="H" type="number" min="0" className={inp + " text-center"} /> + set("length", ev.target.value)} placeholder="L" type="number" min="0" className={inp + " text-center"} disabled={hasMeasurements} /> + set("width", ev.target.value)} placeholder="W" type="number" min="0" className={inp + " text-center"} disabled={hasMeasurements} /> + set("height", ev.target.value)} placeholder="H" type="number" min="0" className={inp + " text-center"} disabled={hasMeasurements} />
+ {hasMeasurements && ( +
+ Field harga, berat, dan dimensi model dinonaktifkan karena mengikuti measurement. +
+ )}
{/* Promotion & Packaging */}
{/* Promotion */} -
+
{e.promotion}
+ {hasMeasurements && ( +

+ Promo model dinonaktifkan karena mengikuti measurement. +

+ )}
@@ -924,53 +971,64 @@ function ModelCard({
{/* Packaging */} -
+
{e.packagingFootprint} + {hasMeasurements && ( +

+ Packaging model dinonaktifkan karena mengikuti measurement. +

+ )}
- set("packagingWeightType", ev.target.value)} className={sel} disabled={hasMeasurements}> {WEIGHT_TYPES.map((w) => )} - set("packagingWeight", ev.target.value)} placeholder="0" type="number" min="0" className={inp} /> + set("packagingWeight", ev.target.value)} placeholder="0" type="number" min="0" className={inp} disabled={hasMeasurements} />
- set("packagingDimensionType", ev.target.value)} className={sel} disabled={hasMeasurements}> {DIMENSION_TYPES.map((d) => )} - set("packagingLength", ev.target.value)} placeholder="L" type="number" min="0" className={inp + " text-center"} /> - set("packagingWidth", ev.target.value)} placeholder="W" type="number" min="0" className={inp + " text-center"} /> - set("packagingHeight", ev.target.value)} placeholder="H" type="number" min="0" className={inp + " text-center"} /> + set("packagingLength", ev.target.value)} placeholder="L" type="number" min="0" className={inp + " text-center"} disabled={hasMeasurements} /> + set("packagingWidth", ev.target.value)} placeholder="W" type="number" min="0" className={inp + " text-center"} disabled={hasMeasurements} /> + set("packagingHeight", ev.target.value)} placeholder="H" type="number" min="0" className={inp + " text-center"} disabled={hasMeasurements} />
{/* Model Warehouses */} -
+
+ {hasMeasurements && ( +

+ Stok model dinonaktifkan karena mengikuti measurement. +

+ )}
{model.warehouses.map((wh, wi) => (
- updateWh(wi, "id", ev.target.value)} disabled={hasMeasurements} className="flex-1 bg-surface-container-low rounded-xl border border-outline-variant/10 p-3.5 text-sm font-semibold focus:ring-2 focus:ring-primary/10 focus:outline-none"> {warehouses.map((w) => )} - updateWh(wi, "stock", Number(ev.target.value || 0))} placeholder={e.stock} type="number" min="0" + updateWh(wi, "stock", Number(ev.target.value || 0))} placeholder={e.stock} type="number" min="0" disabled={hasMeasurements} className="w-24 bg-surface-container-low rounded-xl border border-outline-variant/10 p-3.5 text-sm font-semibold text-center focus:ring-2 focus:ring-primary/10 focus:outline-none" /> {model.warehouses.length > 1 && ( - @@ -981,6 +1039,7 @@ function ModelCard({
{/* Measurements */} + {hasMeasurements && (
@@ -1008,6 +1067,7 @@ function ModelCard({
)}
+ )}
); @@ -1219,28 +1279,28 @@ function EditProductPageInner() { name: m.name, sku: m.sku, imageId: m.imageId || undefined, - price: toNum(m.price), + price: m.hasMeasurements ? 0 : toNum(m.price), currency: m.currency, - weight: toNum(m.weight), + weight: m.hasMeasurements ? 0 : toNum(m.weight), weightType: m.weightType || "G", - length: toNum(m.length), - width: toNum(m.width), - height: toNum(m.height), + length: m.hasMeasurements ? 0 : toNum(m.length), + width: m.hasMeasurements ? 0 : toNum(m.width), + height: m.hasMeasurements ? 0 : toNum(m.height), dimensionType: m.dimensionType || "CM", - isMeasurement: m.measurements.length > 0, - isConfigurePromotionPrice: m.hasPromotion, - promotionPrice: m.hasPromotion ? toNum(m.promotionPrice) : null, - promotionCurrency: m.hasPromotion ? (m.promotionCurrency || m.currency) : null, - promotionStartDate: m.hasPromotion ? (m.promotionStartDate || null) : null, - promotionEndDate: m.hasPromotion ? (m.promotionEndDate || null) : null, - packagingWeight: toNum(m.packagingWeight), + isMeasurement: m.hasMeasurements, + isConfigurePromotionPrice: m.hasMeasurements ? false : m.hasPromotion, + promotionPrice: m.hasMeasurements ? null : m.hasPromotion ? toNum(m.promotionPrice) : null, + promotionCurrency: m.hasMeasurements ? null : m.hasPromotion ? (m.promotionCurrency || m.currency) : null, + promotionStartDate: m.hasMeasurements ? null : m.hasPromotion ? (m.promotionStartDate || null) : null, + promotionEndDate: m.hasMeasurements ? null : m.hasPromotion ? (m.promotionEndDate || null) : null, + packagingWeight: m.hasMeasurements ? 0 : toNum(m.packagingWeight), packagingWeightType: m.packagingWeightType || "G", - packagingLength: toNum(m.packagingLength), - packagingWidth: toNum(m.packagingWidth), - packagingHeight: toNum(m.packagingHeight), + packagingLength: m.hasMeasurements ? 0 : toNum(m.packagingLength), + packagingWidth: m.hasMeasurements ? 0 : toNum(m.packagingWidth), + packagingHeight: m.hasMeasurements ? 0 : toNum(m.packagingHeight), packagingDimensionType: m.packagingDimensionType || "CM", - warehouses: m.warehouses.filter((w) => w.id).map((w) => ({ id: w.id, stock: Number(w.stock || 0) })), - productMeasurements: m.measurements.map((ms) => ({ + warehouses: m.hasMeasurements ? [] : m.warehouses.filter((w) => w.id).map((w) => ({ id: w.id, stock: Number(w.stock || 0) })), + productMeasurements: m.hasMeasurements ? m.measurements.map((ms) => ({ measurementType: ms.measurementType, measurementValue: ms.measurementValue, price: toNum(ms.price), @@ -1263,7 +1323,7 @@ function EditProductPageInner() { promotionStartDate: ms.hasPromotion ? (ms.promotionStartDate || null) : null, promotionEndDate: ms.hasPromotion ? (ms.promotionEndDate || null) : null, warehouses: ms.warehouses.filter((w) => w.id).map((w) => ({ id: w.id, stock: Number(w.stock || 0) })), - })), + })) : [], })), productInformations: form.productInformations.filter((i) => i.paramName && i.paramValue), categoryInformations: form.categoryInformations.filter((i) => i.paramName && i.paramValue), diff --git a/src/app/(dashboard)/products/new/pricing/page.tsx b/src/app/(dashboard)/products/new/pricing/page.tsx index f06ce93..b7d5c5c 100644 --- a/src/app/(dashboard)/products/new/pricing/page.tsx +++ b/src/app/(dashboard)/products/new/pricing/page.tsx @@ -499,6 +499,17 @@ function ModelCard({ onChange({ ...model, [field]: value }); } + function toggleMeasurements(enabled: boolean) { + onChange({ + ...model, + hasMeasurements: enabled, + measurements: + enabled && model.measurements.length === 0 + ? [newMeasurement()] + : model.measurements, + }); + } + function updateWarehouse(whIndex: number, field: "id" | "stock", value: string | number) { const updated = model.warehouses.map((w, i) => { if (i !== whIndex) return w; @@ -538,6 +549,7 @@ function ModelCard({ const inpBase = "w-full bg-white border border-neutral-200 rounded-lg px-3 py-2 text-sm font-semibold focus:ring-1 focus:ring-[#e53935]/20 focus:outline-none"; const inpGray = "w-full bg-[#f8f9fa] border-none rounded-lg p-3 text-sm font-bold focus:ring-2 focus:ring-[#e53935]/10 focus:outline-none"; const selGray = "w-full bg-[#f8f9fa] border-none rounded-lg p-3 text-sm font-bold focus:ring-2 focus:ring-[#e53935]/10 focus:outline-none"; + const disabledBlock = model.hasMeasurements ? "opacity-50 pointer-events-none" : ""; return (
@@ -573,6 +585,27 @@ function ModelCard({ onChange={(e) => set("sku", e.target.value)} />
+
+
+
+

+ Product Measurement +

+

+ Use nested measurement rows for price, weight, dimension, and stock. +

+
+ +
+
{total > 1 && ( @@ -601,7 +634,7 @@ function ModelCard({
{/* Pricing & Physical Specs */} -
+
@@ -636,6 +671,7 @@ function ModelCard({ className={selGray} value={model.weightType} onChange={(e) => set("weightType", e.target.value)} + disabled={model.hasMeasurements} > {WEIGHT_TYPES.map((w) => ( @@ -653,6 +689,7 @@ function ModelCard({ placeholder="0" value={model.weight} onChange={(e) => set("weight", e.target.value)} + disabled={model.hasMeasurements} />
@@ -664,23 +701,29 @@ function ModelCard({ className="bg-[#f8f9fa] border-none rounded-lg p-3 text-[10px] font-bold focus:ring-2 focus:ring-[#e53935]/10 focus:outline-none w-full" value={model.dimensionType} onChange={(e) => set("dimensionType", e.target.value)} + disabled={model.hasMeasurements} > {DIMENSION_TYPES.map((d) => ( ))} - set("length", e.target.value)} /> - set("width", e.target.value)} /> - set("height", e.target.value)} /> + set("length", e.target.value)} disabled={model.hasMeasurements} /> + set("width", e.target.value)} disabled={model.hasMeasurements} /> + set("height", e.target.value)} disabled={model.hasMeasurements} />
+ {model.hasMeasurements && ( +
+ Model-level currency, base price, weight, and dimensions are disabled while product measurement is enabled. +
+ )}
{/* Promotions & Packaging */}
{/* Promotions */} -
+

campaign @@ -692,10 +735,16 @@ function ModelCard({ className="sr-only peer" checked={model.hasPromotion} onChange={(e) => set("hasPromotion", e.target.checked)} + disabled={model.hasMeasurements} />

+ {model.hasMeasurements && ( +

+ Promotion is managed per measurement when product measurement is enabled. +

+ )}
@@ -753,11 +802,16 @@ function ModelCard({
{/* Packaging */} -
+

package_2 Packaging Footprint

+ {model.hasMeasurements && ( +

+ Packaging values are managed per measurement when product measurement is enabled. +

+ )}
@@ -791,21 +847,22 @@ function ModelCard({ className="bg-[#f8f9fa] border-none rounded-lg p-2 text-[10px] font-bold focus:ring-1 focus:ring-[#e53935]/20 focus:outline-none w-full" value={model.packagingDimensionType} onChange={(e) => set("packagingDimensionType", e.target.value)} + disabled={model.hasMeasurements} > {DIMENSION_TYPES.map((d) => ( ))} - set("packagingLength", e.target.value)} /> - set("packagingWidth", e.target.value)} /> - set("packagingHeight", e.target.value)} /> + set("packagingLength", e.target.value)} disabled={model.hasMeasurements} /> + set("packagingWidth", e.target.value)} disabled={model.hasMeasurements} /> + set("packagingHeight", e.target.value)} disabled={model.hasMeasurements} />
{/* Warehouse Stock Allocation */} -
+

warehouse @@ -814,11 +871,17 @@ function ModelCard({

+ {model.hasMeasurements && ( +

+ Stock allocation is managed per measurement when product measurement is enabled. +

+ )}
{model.warehouses.map((wh, whIndex) => (
@@ -827,6 +890,7 @@ function ModelCard({ className="w-full bg-[#f8f9fa] border-none rounded-lg px-3 py-2 text-xs font-bold focus:ring-1 focus:ring-[#e53935]/20 focus:outline-none" value={wh.id} onChange={(e) => updateWarehouse(whIndex, "id", e.target.value)} + disabled={model.hasMeasurements} > {warehouses.map((w) => ( @@ -844,12 +908,14 @@ function ModelCard({ min="0" value={wh.stock} onChange={(e) => updateWarehouse(whIndex, "stock", Number(e.target.value || 0))} + disabled={model.hasMeasurements} />
{model.warehouses.length > 1 && (
{/* Nested Measurements */} + {model.hasMeasurements && (

@@ -900,6 +967,7 @@ function ModelCard({

)}
+ )}
@@ -971,18 +1039,49 @@ export default function ProductPricingPage() { setValidationError(`Model ${i + 1}: Nama model wajib diisi`); return; } - if (!m.price.trim()) { - setValidationError(`Model ${i + 1}: Harga wajib diisi`); - return; - } - if (!m.weight.trim()) { - setValidationError(`Model ${i + 1}: Berat wajib diisi`); - return; - } - const hasWarehouse = m.warehouses.some((w) => w.id); - if (!hasWarehouse) { - setValidationError(`Model ${i + 1}: Pilih minimal satu gudang`); - return; + if (m.hasMeasurements) { + if (m.measurements.length < 1) { + setValidationError(`Model ${i + 1}: Product measurement wajib diisi minimal 1 baris`); + return; + } + for (let j = 0; j < m.measurements.length; j++) { + const measurement = m.measurements[j]; + if (!measurement.measurementType.trim()) { + setValidationError(`Model ${i + 1} Measurement ${j + 1}: Measurement type wajib diisi`); + return; + } + if (!measurement.measurementValue.trim()) { + setValidationError(`Model ${i + 1} Measurement ${j + 1}: Measurement value wajib diisi`); + return; + } + if (!measurement.price.trim()) { + setValidationError(`Model ${i + 1} Measurement ${j + 1}: Harga wajib diisi`); + return; + } + if (!measurement.weight.trim()) { + setValidationError(`Model ${i + 1} Measurement ${j + 1}: Berat wajib diisi`); + return; + } + const hasMeasurementWarehouse = measurement.warehouses.some((w) => w.id); + if (!hasMeasurementWarehouse) { + setValidationError(`Model ${i + 1} Measurement ${j + 1}: Pilih minimal satu gudang`); + return; + } + } + } else { + if (!m.price.trim()) { + setValidationError(`Model ${i + 1}: Harga wajib diisi`); + return; + } + if (!m.weight.trim()) { + setValidationError(`Model ${i + 1}: Berat wajib diisi`); + return; + } + const hasWarehouse = m.warehouses.some((w) => w.id); + if (!hasWarehouse) { + setValidationError(`Model ${i + 1}: Pilih minimal satu gudang`); + return; + } } } router.push("/products/new/specifications"); diff --git a/src/app/(dashboard)/products/new/review/page.tsx b/src/app/(dashboard)/products/new/review/page.tsx index 32137b2..207fdac 100644 --- a/src/app/(dashboard)/products/new/review/page.tsx +++ b/src/app/(dashboard)/products/new/review/page.tsx @@ -6,11 +6,19 @@ import { useProductDraft } from "@/lib/product-draft"; import { useProductSubmit } from "@/lib/use-product-submit"; import { useLanguage } from "@/lib/i18n-context"; +const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id"; + function getToken() { if (typeof window === "undefined") return ""; return sessionStorage.getItem("token") || localStorage.getItem("token") || ""; } +function imgUrl(id: string | null | undefined) { + if (!id) return null; + if (id.startsWith("http")) return id; + return `${API_BASE}/api/v1.0/file/image/${id}`; +} + function toNumber(value: string) { const normalized = value.replace(/\./g, "").replace(/,/g, "."); const parsed = Number(normalized); @@ -61,6 +69,10 @@ export default function ProductReviewPage() { const r = t.dashboard.productNew.review; const [errorLogCopied, setErrorLogCopied] = useState(false); const [warehouseMap, setWarehouseMap] = useState>({}); + const galleryImageIds = draft.productImages.filter(Boolean); + const reviewImageIds = [draft.imageId, ...galleryImageIds].filter( + (value): value is string => Boolean(value) + ); useEffect(() => { async function loadWarehouses() { @@ -156,20 +168,47 @@ export default function ProductReviewPage() {
)} -
- {draft.imageId && ( -
- image - {r.mainImage} + {reviewImageIds.length > 0 ? ( +
+
+ {reviewImageIds.map((imageId, index) => { + const url = imgUrl(imageId); + if (!url) return null; + const isMainImage = index === 0 && Boolean(draft.imageId); + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {isMainImage +

+ {isMainImage ? r.mainImage : `${r.gallery} ${draft.imageId ? index : index + 1}`} +

+
+ ); + })}
- )} - {draft.productImages.filter(Boolean).length > 0 && ( -
- photo_library - {draft.productImages.filter(Boolean).length} {r.gallery} +
+ {draft.imageId && ( +
+ image + {r.mainImage} +
+ )} + {galleryImageIds.length > 0 && ( +
+ photo_library + {galleryImageIds.length} {r.gallery} +
+ )}
- )} -
+
+ ) : null}
{/* Pricing & Models */} @@ -187,9 +226,32 @@ export default function ProductReviewPage() { {/* Model core info */}
- - - + + + {model.hasPromotion && } {model.hasPromotion && model.promotionStartDate && ( @@ -199,7 +261,7 @@ export default function ProductReviewPage() {
{/* Warehouse stock */} - {model.warehouses.filter((w) => w.id).length > 0 && ( + {!model.hasMeasurements && model.warehouses.filter((w) => w.id).length > 0 && (

{r.warehouseStock}

{model.warehouses.filter((w) => w.id).map((w, wi) => ( @@ -212,7 +274,7 @@ export default function ProductReviewPage() { )} {/* Measurements / Variants */} - {model.measurements.length > 0 && ( + {model.hasMeasurements && model.measurements.length > 0 && (

{r.measurements} ({model.measurements.length}) diff --git a/src/app/(dashboard)/products/page.tsx b/src/app/(dashboard)/products/page.tsx index c59a75d..a24e0b2 100644 --- a/src/app/(dashboard)/products/page.tsx +++ b/src/app/(dashboard)/products/page.tsx @@ -41,6 +41,8 @@ interface ProductWarehouseRef { interface ProductMeasurementRef { id?: string | number | null; productMeasurementId?: string | number | null; + measurementType?: string | null; + measurementValue?: string | null; price?: string | number | null; warehouses?: ProductWarehouseRef[]; } @@ -48,6 +50,8 @@ interface ProductMeasurementRef { interface ProductModelRef { id?: string | number | null; productModelId?: string | number | null; + name?: string | null; + sku?: string | null; isMeasurement?: boolean | null; price?: string | number | null; warehouses?: ProductWarehouseRef[]; @@ -58,15 +62,33 @@ interface ProductDetailRef { productModels?: ProductModelRef[]; } -interface StockPriceTarget { - product: ProductRow; +interface WarehouseLookup { + id: string; + name?: string | null; + address?: string | null; + city?: string | null; +} + +interface StockPriceOption { + key: string; + modelId: string; + modelLabel: string; + measurementId: string; + measurementLabel: string; + warehouseId: string; + warehouseLabel: string; currentPrice: number; currentStock: number; +} + +interface StockPriceTarget { + product: ProductRow; nextPrice: string; nextStock: string; productModelId: string; productMeasurementId: string; warehouseId: string; + options: StockPriceOption[]; loading: boolean; submitting: boolean; error: string; @@ -114,67 +136,176 @@ function formatCurrencyValue(value: number) { return `Rp ${new Intl.NumberFormat("id-ID").format(value)}`; } -function resolveStockPriceFields(product: ProductDetailRef) { +function getProductStateMeta( + productState: ProductState, + p: ReturnType["t"]["dashboard"]["products"] +) { + switch (productState) { + case "DRAFT": + return { + label: p.states.draft, + className: "bg-surface-container text-on-surface-variant", + }; + case "REVIEW": + case "IN_REVIEW": + return { + label: p.states.inReview, + className: "bg-secondary-container/70 text-on-secondary-container", + }; + case "UNPUBLISHED": + return { + label: p.states.unpublished, + className: "bg-amber-100 text-amber-800", + }; + case "DELETED_BY_SELLER": + return { + label: p.states.deletedBySeller, + className: "bg-error-container text-on-error-container", + }; + case "DELETED_BY_ADMIN": + return { + label: p.states.deletedByAdmin, + className: "bg-error text-white", + }; + case "REJECT": + case "REJECTED": + return { + label: p.states.rejected, + className: "bg-rose-100 text-rose-800", + }; + case "PUBLISHED": + case "ACTIVE": + case "APPROVED": + return { + label: p.states.active, + className: "bg-green-100 text-green-800", + }; + default: + return { + label: productState || p.states.unknown, + className: "bg-surface-container text-on-surface-variant", + }; + } +} + +function buildMeasurementLabel(measurement: ProductMeasurementRef, index: number) { + const type = String(measurement.measurementType || "").trim(); + const value = String(measurement.measurementValue || "").trim(); + + if (type && value) return `${type}: ${value}`; + if (value) return value; + if (type) return type; + + return `Measurement ${index + 1}`; +} + +function buildModelLabel(model: ProductModelRef, index: number) { + const name = String(model.name || "").trim(); + const sku = String(model.sku || "").trim(); + const parts = [`Model ${index + 1}`]; + + if (name) parts.push(name); + if (sku) parts.push(`SKU ${sku}`); + + return parts.join(" - "); +} + +function buildWarehouseLabel( + warehouse: ProductWarehouseRef, + fallbackIndex: number, + warehouseLookupMap: Record +) { + const warehouseId = toId(warehouse.id ?? warehouse.warehouseId); + const lookup = warehouseId ? warehouseLookupMap[warehouseId] : null; + const warehouseName = lookup?.name || lookup?.address || ""; + const warehouseCity = lookup?.city || ""; + + if (warehouseName && warehouseCity) { + return `${warehouseName} — ${warehouseCity}`; + } + + if (warehouseName) { + return warehouseName; + } + + return warehouseId ? `Warehouse ${warehouseId}` : `Warehouse ${fallbackIndex + 1}`; +} + +function buildStockPriceOptions( + product: ProductDetailRef, + warehouseLookupMap: Record +) { const models = Array.isArray(product.productModels) ? product.productModels : []; - const model = models[0]; + const options: StockPriceOption[] = []; - if (!model) { - return null; - } + models.forEach((model, modelIndex) => { + const modelId = toId(model.id ?? model.productModelId); + if (!modelId) return; - 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); + const modelLabel = buildModelLabel(model, modelIndex); + const modelWarehouses = Array.isArray(model.warehouses) ? model.warehouses : []; + const modelWarehousesWithIds = modelWarehouses.filter( + (warehouse) => Boolean(toId(warehouse.id ?? warehouse.warehouseId)) + ); + const measurements = Array.isArray(model.productMeasurements) + ? model.productMeasurements + : []; - if (!productModelId || !modelWarehouseId) { - return null; - } + if (model.isMeasurement === false || measurements.length === 0) { + modelWarehousesWithIds.forEach((warehouse, warehouseIndex) => { + const warehouseId = toId(warehouse.id ?? warehouse.warehouseId); + if (!warehouseId) return; - if (model.isMeasurement === false) { - return { - productModelId, - productMeasurementId: "", - warehouseId: modelWarehouseId, - currentPrice: toNumber(model.price), - currentStock: toNumber(modelWarehouse?.stock), - }; - } + options.push({ + key: [modelId, "", warehouseId].join("::"), + modelId, + modelLabel, + measurementId: "", + measurementLabel: "", + warehouseId, + warehouseLabel: buildWarehouseLabel(warehouse, warehouseIndex, warehouseLookupMap), + currentPrice: toNumber(model.price), + currentStock: toNumber(warehouse.stock), + }); + }); - const measurements = Array.isArray(model.productMeasurements) - ? model.productMeasurements - : []; - const measurement = measurements[0]; + return; + } - if (!measurement) { - return { - productModelId, - productMeasurementId: "", - warehouseId: modelWarehouseId, - currentPrice: toNumber(model.price), - currentStock: toNumber(modelWarehouse?.stock), - }; - } + measurements.forEach((measurement, measurementIndex) => { + const measurementId = toId(measurement.id ?? measurement.productMeasurementId); + const measurementLabel = buildMeasurementLabel(measurement, measurementIndex); + const rawMeasurementWarehouses = Array.isArray(measurement.warehouses) + ? measurement.warehouses + : []; + const measurementWarehousesWithIds = rawMeasurementWarehouses.filter( + (warehouse) => Boolean(toId(warehouse.id ?? warehouse.warehouseId)) + ); + const measurementWarehouses = + measurementWarehousesWithIds.length > 0 + ? measurementWarehousesWithIds + : modelWarehousesWithIds; - const measurementWarehouses = Array.isArray(measurement.warehouses) - ? measurement.warehouses - : []; - const warehouse = measurementWarehouses[0] || modelWarehouse; - const warehouseId = toId(warehouse?.id ?? warehouse?.warehouseId); + measurementWarehouses.forEach((warehouse, warehouseIndex) => { + const warehouseId = toId(warehouse.id ?? warehouse.warehouseId); + if (!warehouseId) return; - if (!warehouseId) { - return null; - } + options.push({ + key: [modelId, measurementId, warehouseId].join("::"), + modelId, + modelLabel, + measurementId, + measurementLabel, + warehouseId, + warehouseLabel: buildWarehouseLabel(warehouse, warehouseIndex, warehouseLookupMap), + currentPrice: toNumber(measurement.price ?? model.price), + currentStock: toNumber(warehouse.stock), + }); + }); + }); + }); - return { - productModelId, - productMeasurementId: toId( - measurement.id ?? measurement.productMeasurementId - ), - warehouseId, - currentPrice: toNumber(measurement.price ?? model.price), - currentStock: toNumber(warehouse?.stock), - }; + return options; } function tabFromQuery(tab: string | null): TabLabel { @@ -284,6 +415,9 @@ function StockPriceModal({ state, d, onCancel, + onModelChange, + onMeasurementChange, + onWarehouseChange, onPriceChange, onStockChange, onSubmit, @@ -297,15 +431,60 @@ function StockPriceModal({ currentStock: string; newPrice: string; newStock: string; + model: string; + measurement: string; + warehouse: string; + noMeasurement: string; cancel: string; confirm: string; processing: string; }; onCancel: () => void; + onModelChange: (value: string) => void; + onMeasurementChange: (value: string) => void; + onWarehouseChange: (value: string) => void; onPriceChange: (value: string) => void; onStockChange: (value: string) => void; onSubmit: () => void; }) { + const selectedOption = + state.options.find( + (option) => + option.modelId === state.productModelId && + option.measurementId === state.productMeasurementId && + option.warehouseId === state.warehouseId + ) || null; + + const modelOptions = Array.from( + new Map( + state.options.map((option) => [option.modelId, { value: option.modelId, label: option.modelLabel }]) + ).values() + ); + const measurementOptions = Array.from( + new Map( + state.options + .filter((option) => option.modelId === state.productModelId && option.measurementId) + .map((option) => [ + option.measurementId, + { value: option.measurementId, label: option.measurementLabel }, + ]) + ).values() + ); + const warehouseOptions = Array.from( + new Map( + state.options + .filter( + (option) => + option.modelId === state.productModelId && + option.measurementId === state.productMeasurementId + ) + .map((option) => [ + option.warehouseId, + { value: option.warehouseId, label: option.warehouseLabel }, + ]) + ).values() + ); + return (

@@ -326,17 +505,75 @@ function StockPriceModal({

ID: {state.product.id}

+
+ + + +
+

{d.currentPrice}

- {state.loading ? d.processing : formatCurrencyValue(state.currentPrice)} + {state.loading ? d.processing : formatCurrencyValue(selectedOption?.currentPrice || 0)}

{d.currentStock}

- {state.loading ? d.processing : state.currentStock} + {state.loading ? d.processing : selectedOption?.currentStock || 0}

@@ -434,6 +671,7 @@ function ProductsPageInner() { const [publishingId, setPublishingId] = useState(null); const [restoringId, setRestoringId] = useState(null); const [stockPriceTarget, setStockPriceTarget] = useState(null); + const [warehouseLookupMap, setWarehouseLookupMap] = useState>({}); const [openActionMenuId, setOpenActionMenuId] = useState(null); const actionMenuRef = useRef(null); @@ -469,6 +707,34 @@ function ProductsPageInner() { }; }, []); + useEffect(() => { + async function loadWarehouses() { + try { + const res = await fetch("/api/products/warehouses?size=100", { + headers: { "x-auth-token": getToken() }, + }); + const result = await res.json().catch(() => ({})); + const rows = Array.isArray(result?.rows) + ? result.rows + : Array.isArray(result?.data) + ? result.data + : []; + + setWarehouseLookupMap( + rows.reduce((acc: Record, row: WarehouseLookup) => { + if (!row?.id) return acc; + acc[String(row.id)] = row; + return acc; + }, {}) + ); + } catch { + setWarehouseLookupMap({}); + } + } + + loadWarehouses(); + }, []); + useEffect(() => { async function loadProducts() { setLoading(true); @@ -609,13 +875,12 @@ function ProductsPageInner() { setStockPriceTarget({ product, - currentPrice: product.minPrice || 0, - currentStock: product.totalStock || 0, nextPrice: String(product.minPrice || 0), nextStock: String(product.totalStock || 0), productModelId: "", productMeasurementId: "", warehouseId: "", + options: [], loading: true, submitting: false, error: "", @@ -632,9 +897,15 @@ function ProductsPageInner() { throw new Error(result?.responseDesc || p.stockPriceDialog.loadError); } - const resolved = resolveStockPriceFields(result?.data || result); + const payload = result?.data || result; + const options = buildStockPriceOptions(payload, warehouseLookupMap); + const resolved = options[0]; - if (!resolved) { + if (!resolved || options.length === 0) { + console.warn("[stock-price-modal] unable to resolve options", { + productId: product.id, + payload, + }); throw new Error(p.stockPriceDialog.targetError); } @@ -642,7 +913,10 @@ function ProductsPageInner() { current && current.product.id === product.id ? { ...current, - ...resolved, + productModelId: resolved.modelId, + productMeasurementId: resolved.measurementId, + warehouseId: resolved.warehouseId, + options, nextPrice: String(resolved.currentPrice), nextStock: String(resolved.currentStock), loading: false, @@ -665,6 +939,69 @@ function ProductsPageInner() { } } + function handleStockPriceModelChange(modelId: string) { + setStockPriceTarget((current) => { + if (!current) return current; + + const nextOption = current.options.find((option) => option.modelId === modelId); + if (!nextOption) return current; + + return { + ...current, + productModelId: nextOption.modelId, + productMeasurementId: nextOption.measurementId, + warehouseId: nextOption.warehouseId, + nextPrice: String(nextOption.currentPrice), + nextStock: String(nextOption.currentStock), + error: "", + }; + }); + } + + function handleStockPriceMeasurementChange(measurementId: string) { + setStockPriceTarget((current) => { + if (!current) return current; + + const nextOption = current.options.find( + (option) => + option.modelId === current.productModelId && + option.measurementId === measurementId + ); + if (!nextOption) return current; + + return { + ...current, + productMeasurementId: nextOption.measurementId, + warehouseId: nextOption.warehouseId, + nextPrice: String(nextOption.currentPrice), + nextStock: String(nextOption.currentStock), + error: "", + }; + }); + } + + function handleStockPriceWarehouseChange(warehouseId: string) { + setStockPriceTarget((current) => { + if (!current) return current; + + const nextOption = current.options.find( + (option) => + option.modelId === current.productModelId && + option.measurementId === current.productMeasurementId && + option.warehouseId === warehouseId + ); + if (!nextOption) return current; + + return { + ...current, + warehouseId: nextOption.warehouseId, + nextPrice: String(nextOption.currentPrice), + nextStock: String(nextOption.currentStock), + error: "", + }; + }); + } + async function handleSubmitStockPrice() { if (!stockPriceTarget) return; @@ -871,6 +1208,7 @@ function ProductsPageInner() { ) : ( rows.map((product) => { const productState = getProductState(product); + const productStateMeta = getProductStateMeta(productState, p); const isInactiveInAllTab = activeTab === "All Product" && (productState === "UNPUBLISHED" || @@ -925,9 +1263,16 @@ function ProductsPageInner() { > {product.name}

-

- ID: {product.id.slice(0, 8)} -

+
+

+ ID: {product.id.slice(0, 8)} +

+ + {productStateMeta.label} + +
@@ -1231,6 +1576,9 @@ function ProductsPageInner() { setStockPriceTarget(null); } }} + onModelChange={handleStockPriceModelChange} + onMeasurementChange={handleStockPriceMeasurementChange} + onWarehouseChange={handleStockPriceWarehouseChange} onPriceChange={(value) => setStockPriceTarget((current) => current ? { ...current, nextPrice: value, error: "" } : current diff --git a/src/app/admin/review/[productId]/page.tsx b/src/app/admin/review/[productId]/page.tsx index cb700d3..b791efd 100644 --- a/src/app/admin/review/[productId]/page.tsx +++ b/src/app/admin/review/[productId]/page.tsx @@ -16,6 +16,241 @@ function imgUrl(id: string | null | undefined) { return `${API_BASE}/api/v1.0/file/image/${id}`; } +function isNonEmptyString(value: string | undefined): value is string { + return typeof value === "string" && value.length > 0; +} + +function formatMoney(value?: string | number | null, currency?: string | null) { + if (value === "" || value === undefined || value === null) return undefined; + const amount = Number(value); + if (!Number.isFinite(amount)) return undefined; + return `${currency || "IDR"} ${amount.toLocaleString("id-ID")}`; +} + +function formatDimension( + length?: string | number | null, + width?: string | number | null, + height?: string | number | null, + dimensionType?: string | null +) { + const parts = [length, width, height].filter( + (value) => value !== "" && value !== undefined && value !== null && Number(value) !== 0 + ); + if (parts.length === 0) return undefined; + return `${parts.join(" × ")} ${dimensionType || ""}`.trim(); +} + +function formatWeight(value?: string | number | null, weightType?: string | null) { + if (value === "" || value === undefined || value === null || Number(value) === 0) return undefined; + return `${value} ${weightType || ""}`.trim(); +} + +function formatMeasurementLabel( + measurement: { measurementType?: string; measurementValue?: string }, + index: number +) { + const parts = [measurement.measurementType, measurement.measurementValue].filter(Boolean); + return parts.length > 0 ? parts.join(" - ") : `Measurement ${index + 1}`; +} + +interface ReviewWarehouse { + id?: string; + city?: string; + province?: string; + country?: string; + stock?: number; +} + +interface ReviewMeasurement { + 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?: ReviewWarehouse[]; +} + +interface ReviewModel extends ReviewMeasurement { + name?: string; + sku?: string; + imageId?: string; + warehouses?: ReviewWarehouse[]; + productMeasurements?: ReviewMeasurement[]; +} + +interface ReviewProductData { + name?: string; + description?: string; + state?: string; + imageId?: string; + image?: string; + isNew?: boolean; + isEligibleToExport?: boolean; + isPreOrder?: boolean; + preOrderDay?: string | number; + productImages?: Array<{ sequence?: number; imageId?: string }>; + productModels?: ReviewModel[]; + productKeyWords?: string[]; + productFeatures?: string[]; + subCategory?: { + name?: string; + category?: { + name?: string; + }; + }; + complianceInformation?: { + countryOfOrigin?: string; + safetyWarning?: string; + isDangerousGoodRegulation?: boolean; + }; + warrantyInformation?: { + type?: string; + duration?: string | number; + durationType?: string; + }; + seller?: { + id?: string; + name?: string; + imageId?: string; + }; +} + +interface CompareRow { + field?: string; + oldValue?: unknown; + newValue?: unknown; + isUpdate?: boolean; +} + +function hasChangesForPaths(rows: CompareRow[], paths: string[]) { + return rows.some((row) => { + if (!row?.field || row.isUpdate !== true) return false; + return paths.some( + (path) => + row.field === path || + row.field.startsWith(`${path}.`) || + row.field.startsWith(`${path}[`) + ); + }); +} + +function ModelCard({ + model, + index, + accent, + changed, +}: { + model: ReviewModel; + index: number; + accent?: boolean; + changed?: boolean; +}) { + const measurements = Array.isArray(model.productMeasurements) ? model.productMeasurements : []; + const hasMeasurements = measurements.length > 0; + const modelPrice = formatMoney(model.price, model.currency); + const modelPromotionPrice = formatMoney(model.promotionPrice, model.promotionCurrency || model.currency); + const modelWeight = formatWeight(model.weight, model.weightType); + const modelDimension = formatDimension(model.length, model.width, model.height, model.dimensionType); + + return ( +
+
+ {imgUrl(model.imageId) && ( + // eslint-disable-next-line @next/next/no-img-element + {model.name} + )} +
+

{model.name || `Model ${index + 1}`}

+ {changed && ( +

+ Updated +

+ )} + {hasMeasurements && ( +

+ {measurements.length} measurement variation(s) +

+ )} +
+
+ + + + + {model.isConfigurePromotionPrice && !hasMeasurements && ( + + )} + {!hasMeasurements && Array.isArray(model.warehouses) && model.warehouses.length > 0 && ( +
+

Warehouse & Stok

+ {model.warehouses.map((warehouse: ReviewWarehouse, warehouseIndex: number) => ( +
+ {[warehouse.city, warehouse.province].filter(Boolean).join(", ")} + {warehouse.stock ?? 0} unit +
+ ))} +
+ )} + + {hasMeasurements && ( +
+ {measurements.map((measurement: ReviewMeasurement, measurementIndex: number) => { + const measurementPrice = + formatMoney(measurement.price, measurement.currency || model.currency) || modelPrice || "-"; + const measurementPromotionPrice = formatMoney( + measurement.promotionPrice, + measurement.promotionCurrency || measurement.currency || model.currency + ); + const measurementWeight = formatWeight(measurement.weight, measurement.weightType); + const measurementDimension = formatDimension( + measurement.length, + measurement.width, + measurement.height, + measurement.dimensionType + ); + + return ( +
+

+ {formatMeasurementLabel(measurement, measurementIndex)} +

+ + + + {measurement.isConfigurePromotionPrice && ( + + )} + {Array.isArray(measurement.warehouses) && measurement.warehouses.length > 0 && ( +
+

Warehouse & Stok

+ {measurement.warehouses.map((warehouse: ReviewWarehouse, warehouseIndex: number) => ( +
+ {[warehouse.city, warehouse.province].filter(Boolean).join(", ")} + {warehouse.stock ?? 0} unit +
+ ))} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} + // ─── Shared sub-components ─────────────────────────────────────────────────── function Row({ label, value }: { label: string; value?: string | number | boolean | null }) { @@ -29,13 +264,32 @@ function Row({ label, value }: { label: string; value?: string | number | boolea ); } -function SectionCard({ title, accent, children }: { title: string; accent?: boolean; children: React.ReactNode }) { +function SectionCard({ + title, + accent, + changed, + children, +}: { + title: string; + accent?: boolean; + changed?: boolean; + children: React.ReactNode; +}) { return ( -
-

- {title} -

- {children} +
+
+

+ {title} +

+ {changed && ( + + Updated + + )} +
+
+ {children} +
); } @@ -81,8 +335,17 @@ function isProductUpdateFromCompare( return change.isUpdate === true || hasDifferentIds; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function ProductColumn({ product, label, accent }: { product: any; label: string; accent?: boolean }) { +function ProductColumn({ + product, + label, + accent, + compareRows = [], +}: { + product: ReviewProductData | null; + label: string; + accent?: boolean; + compareRows?: CompareRow[]; +}) { if (!product) return (
Memuat data...
); @@ -91,6 +354,16 @@ function ProductColumn({ product, label, accent }: { product: any; label: string const images = Array.isArray(product.productImages) ? product.productImages : []; const features = Array.isArray(product.productFeatures) ? product.productFeatures : []; const keywords = Array.isArray(product.productKeyWords) ? product.productKeyWords : []; + const allImages: string[] = [ + ...(product.imageId ? [product.imageId] : []), + ...images + .sort( + (a: { sequence?: number }, b: { sequence?: number }) => + (a.sequence ?? 0) - (b.sequence ?? 0) + ) + .map((img: { imageId?: string }) => img.imageId) + .filter(isNonEmptyString), + ]; return (
@@ -100,11 +373,11 @@ function ProductColumn({ product, label, accent }: { product: any; label: string
{/* Images */} - {(images.length > 0 || imgUrl(product.imageId)) && ( - + {allImages.length > 0 && ( +
- {(images.length > 0 ? images : [{ imageId: product.imageId }]).map((img: { imageId?: string }, i: number) => { - const url = imgUrl(img.imageId); + {allImages.map((imageId: string, i: number) => { + const url = imgUrl(imageId); if (!url) return null; return ( // eslint-disable-next-line @next/next/no-img-element @@ -116,7 +389,20 @@ function ProductColumn({ product, label, accent }: { product: any; label: string )} {/* Basic Info */} - + @@ -130,7 +416,7 @@ function ProductColumn({ product, label, accent }: { product: any; label: string {/* Features */} {features.length > 0 && ( - +
    {features.map((f: string, i: number) => (
  • @@ -144,7 +430,7 @@ function ProductColumn({ product, label, accent }: { product: any; label: string {/* Keywords */} {keywords.length > 0 && ( - +
    {keywords.map((k: string) => ( {k} @@ -155,40 +441,16 @@ function ProductColumn({ product, label, accent }: { product: any; label: string {/* Models */} {models.length > 0 && ( - +
    - {models.map((m: { - name?: string; sku?: string; price?: number; currency?: string; - weight?: number; weightType?: string; length?: number; width?: number; height?: number; - dimensionType?: string; isConfigurePromotionPrice?: boolean; promotionPrice?: number; - imageId?: string; - warehouses?: { id: string; city?: string; province?: string; country?: string; stock?: number }[]; - }, i: number) => ( -
    -
    - {imgUrl(m.imageId) && ( - // eslint-disable-next-line @next/next/no-img-element - {m.name} - )} -

    {m.name || `Model ${i + 1}`}

    -
    - - - - - {m.isConfigurePromotionPrice && } - {Array.isArray(m.warehouses) && m.warehouses.length > 0 && ( -
    -

    Warehouse & Stok

    - {m.warehouses.map((w) => ( -
    - {[w.city, w.province].filter(Boolean).join(", ")} - {w.stock ?? 0} unit -
    - ))} -
    - )} -
    + {models.map((model: ReviewModel, index: number) => ( + ))}
    @@ -196,7 +458,7 @@ function ProductColumn({ product, label, accent }: { product: any; label: string {/* Compliance */} {product.complianceInformation && ( - + @@ -205,7 +467,7 @@ function ProductColumn({ product, label, accent }: { product: any; label: string {/* Warranty */} {product.warrantyInformation && ( - + @@ -223,12 +485,11 @@ function AdminReviewDetailPageInner() { 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(null); // updated (review) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [oldProduct, setOldProduct] = useState(null); // original (compare) + const [product, setProduct] = useState(null); // updated (review) + const [oldProduct, setOldProduct] = useState(null); // original (compare) const [isComparison, setIsComparison] = useState(false); const [isUpdateProduct, setIsUpdateProduct] = useState(false); + const [compareRows, setCompareRows] = useState([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(""); @@ -246,6 +507,7 @@ function AdminReviewDetailPageInner() { setOldProduct(null); setIsComparison(false); setIsUpdateProduct(false); + setCompareRows([]); const reviewFetch = fetch(`/api/admin/review/${params.productId}`, { headers: { "x-auth-token": getToken() }, @@ -276,6 +538,7 @@ function AdminReviewDetailPageInner() { } setProduct(updated); + setCompareRows(Array.isArray(compareData?.data) ? compareData.data : []); const isUpdate = isProductUpdateFromCompare(idChange); setIsUpdateProduct(isUpdate); @@ -436,7 +699,7 @@ function AdminReviewDetailPageInner() { {/* Content — 1 column (isNew) or 2 columns (update) */} {isComparison ? (
    - +
    ) : ( diff --git a/src/components/admin-product-submenu-nav.tsx b/src/components/admin-product-submenu-nav.tsx index 74a068c..298342d 100644 --- a/src/components/admin-product-submenu-nav.tsx +++ b/src/components/admin-product-submenu-nav.tsx @@ -1,7 +1,6 @@ "use client"; -import Link from "next/link"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Suspense } from "react"; const adminProductSubmenu = [ @@ -11,6 +10,7 @@ const adminProductSubmenu = [ function AdminProductSubmenuNavInner() { const pathname = usePathname(); + const router = useRouter(); const searchParams = useSearchParams(); const rawTab = searchParams.get("tab") ?? ""; const currentTab = rawTab === "all" ? "" : rawTab; @@ -20,7 +20,7 @@ function AdminProductSubmenuNavInner() { } return ( -
    +
    {adminProductSubmenu.map((submenu) => { const submenuTab = new URLSearchParams( submenu.href.split("?")[1] || "" @@ -31,17 +31,18 @@ function AdminProductSubmenuNavInner() { (isAllProduct ? currentTab === "" : submenuTab === currentTab); return ( - router.push(submenu.href)} + className={`relative z-10 block w-full rounded-lg px-4 py-2 text-left text-sm font-semibold transition-colors pointer-events-auto ${ isActive ? "bg-white text-primary shadow-sm" : "text-slate-500 hover:bg-slate-100 hover:text-primary" }`} > {submenu.label} - + ); })}
    diff --git a/src/components/product-submenu-nav.tsx b/src/components/product-submenu-nav.tsx index 3b66057..b43119c 100644 --- a/src/components/product-submenu-nav.tsx +++ b/src/components/product-submenu-nav.tsx @@ -1,7 +1,6 @@ "use client"; -import Link from "next/link"; -import { usePathname, useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { Suspense } from "react"; const productSubmenu = [ @@ -17,6 +16,7 @@ const productSubmenu = [ function ProductSubmenuNavInner() { const pathname = usePathname(); + const router = useRouter(); const searchParams = useSearchParams(); const rawTab = searchParams.get("tab") ?? ""; const currentTab = rawTab === "all" ? "" : rawTab; @@ -24,7 +24,7 @@ function ProductSubmenuNavInner() { if (pathname !== "/products" && !pathname.startsWith("/products/")) return null; return ( -
    +
    {productSubmenu.map((submenu) => { const submenuTab = new URLSearchParams( submenu.href.split("?")[1] || "" @@ -35,17 +35,18 @@ function ProductSubmenuNavInner() { (isAllProduct ? currentTab === "" : submenuTab === currentTab); return ( - router.push(submenu.href)} + className={`relative z-10 flex w-full items-center py-2 pl-3 text-left text-sm font-semibold transition-all rounded-r-xl pointer-events-auto ${ isSubmenuActive ? "bg-white text-primary border-l-4 border-primary shadow-sm rounded-l-none" : "text-on-surface-variant hover:text-primary hover:bg-surface-container/60 border-l-2 border-surface-container" }`} > {submenu.label} - + ); })}
    diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..3766708 --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,5 @@ +import { registerBackendFetchLogger } from "@/lib/backend-fetch-logger"; + +export async function register() { + registerBackendFetchLogger(); +} diff --git a/src/lib/backend-fetch-logger.ts b/src/lib/backend-fetch-logger.ts new file mode 100644 index 0000000..0f29552 --- /dev/null +++ b/src/lib/backend-fetch-logger.ts @@ -0,0 +1,141 @@ +const FETCH_PATCHED = Symbol.for("ina-trading.backend-fetch-logger.patched"); +const MAX_PREVIEW_LENGTH = 1000; + +function isLoggingEnabled() { + return process.env.DEBUG_BACKEND_PROXY === "true" || process.env.NODE_ENV === "development"; +} + +function getBackendOrigin() { + const rawUrl = process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id"; + + try { + return new URL(rawUrl).origin; + } catch { + return rawUrl; + } +} + +function isBackendRequest(input: RequestInfo | URL) { + const backendOrigin = getBackendOrigin(); + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + return url.startsWith(backendOrigin); +} + +function redactHeaders(headers: Headers) { + const result: Record = {}; + + headers.forEach((value, key) => { + if (key.toLowerCase() === "authorization") { + result[key] = value.startsWith("Bearer ") ? "Bearer [redacted]" : "[redacted]"; + return; + } + + result[key] = value; + }); + + return result; +} + +async function readRequestBodyPreview(body: BodyInit | null | undefined) { + if (!body) return null; + + if (typeof body === "string") { + return body.slice(0, MAX_PREVIEW_LENGTH); + } + + if (body instanceof URLSearchParams) { + return body.toString().slice(0, MAX_PREVIEW_LENGTH); + } + + if (typeof FormData !== "undefined" && body instanceof FormData) { + const entries = Array.from(body.entries()).map(([key, value]) => [ + key, + typeof value === "string" + ? value.slice(0, 120) + : { + filename: value.name, + type: value.type, + size: value.size, + }, + ]); + + return JSON.stringify(entries).slice(0, MAX_PREVIEW_LENGTH); + } + + return `[${Object.prototype.toString.call(body)}]`; +} + +async function readResponsePreview(response: Response) { + const contentType = response.headers.get("content-type") || ""; + + if ( + !contentType.includes("application/json") && + !contentType.startsWith("text/") && + !contentType.includes("application/problem+json") + ) { + return `[non-text response: ${contentType || "unknown content-type"}]`; + } + + const rawText = await response.clone().text(); + return rawText.slice(0, MAX_PREVIEW_LENGTH); +} + +export function registerBackendFetchLogger() { + if (!isLoggingEnabled()) return; + + const fetchRef = globalThis.fetch as typeof fetch & { [FETCH_PATCHED]?: boolean }; + if (fetchRef[FETCH_PATCHED]) return; + + const originalFetch = globalThis.fetch.bind(globalThis); + const patchedFetch: typeof fetch = async (input, init) => { + if (!isBackendRequest(input)) { + return originalFetch(input, init); + } + + const request = new Request(input, init); + const requestId = `backend-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const startedAt = Date.now(); + const requestPreview = await readRequestBodyPreview(init?.body); + + console.info("[backend-fetch:request]", { + requestId, + method: request.method, + url: request.url, + headers: redactHeaders(request.headers), + bodyPreview: requestPreview, + }); + + try { + const response = await originalFetch(input, init); + const durationMs = Date.now() - startedAt; + const responsePreview = await readResponsePreview(response).catch( + (error) => `[preview unavailable: ${error instanceof Error ? error.message : "unknown error"}]` + ); + + console.info("[backend-fetch:response]", { + requestId, + method: request.method, + url: request.url, + status: response.status, + ok: response.ok, + durationMs, + headers: redactHeaders(response.headers), + bodyPreview: responsePreview, + }); + + return response; + } catch (error) { + console.error("[backend-fetch:error]", { + requestId, + method: request.method, + url: request.url, + durationMs: Date.now() - startedAt, + error: error instanceof Error ? error.message : error, + }); + throw error; + } + }; + + (patchedFetch as typeof fetch & { [FETCH_PATCHED]?: boolean })[FETCH_PATCHED] = true; + globalThis.fetch = patchedFetch; +} diff --git a/src/lib/translations/en.ts b/src/lib/translations/en.ts index 2dc4f60..4dbc4f4 100644 --- a/src/lib/translations/en.ts +++ b/src/lib/translations/en.ts @@ -364,6 +364,16 @@ export const en = { restore: "Restore", unpublish: "Unpublish", deletedByAdmin: "Deleted by admin", + states: { + active: "Active", + draft: "Draft", + inReview: "In review", + unpublished: "Unpublished", + deletedBySeller: "Deleted by seller", + deletedByAdmin: "Deleted by admin", + rejected: "Rejected", + unknown: "Unknown status", + }, table: { product: "Product", price: "Price", @@ -400,6 +410,10 @@ export const en = { currentStock: "Current Stock", newPrice: "New Price", newStock: "New Stock", + model: "Model", + measurement: "Measurement", + warehouse: "Warehouse", + noMeasurement: "No measurement", cancel: "Cancel", confirm: "Submit", processing: "Processing...", diff --git a/src/lib/translations/id.ts b/src/lib/translations/id.ts index bf2adaa..b24c6ef 100644 --- a/src/lib/translations/id.ts +++ b/src/lib/translations/id.ts @@ -365,6 +365,16 @@ export const id = { restore: "Restore", unpublish: "Unpublish", deletedByAdmin: "Dihapus oleh admin", + states: { + active: "Aktif", + draft: "Draft", + inReview: "Dalam tinjauan", + unpublished: "Unpublished", + deletedBySeller: "Dihapus seller", + deletedByAdmin: "Dihapus admin", + rejected: "Ditolak", + unknown: "Status tidak diketahui", + }, table: { product: "Produk", price: "Harga", @@ -401,6 +411,10 @@ export const id = { currentStock: "Stok Sekarang", newPrice: "Harga Baru", newStock: "Stok Baru", + model: "Model", + measurement: "Measurement", + warehouse: "Gudang", + noMeasurement: "Tanpa measurement", cancel: "Batal", confirm: "Submit", processing: "Memproses...", diff --git a/src/lib/use-product-submit.ts b/src/lib/use-product-submit.ts index 50fd27b..014ba2d 100644 --- a/src/lib/use-product-submit.ts +++ b/src/lib/use-product-submit.ts @@ -34,55 +34,59 @@ export function buildProductPayload(draft: ProductDraftState, state: "DRAFT" | " name: model.name, sku: model.sku, imageId: model.imageId || undefined, - price: toNumber(model.price), + price: model.hasMeasurements ? 0 : toNumber(model.price), currency: model.currency, - weight: toNumber(model.weight), + weight: model.hasMeasurements ? 0 : toNumber(model.weight), weightType: model.weightType || "G", - length: toNumber(model.length), - width: toNumber(model.width), - height: toNumber(model.height), + length: model.hasMeasurements ? 0 : toNumber(model.length), + width: model.hasMeasurements ? 0 : toNumber(model.width), + height: model.hasMeasurements ? 0 : toNumber(model.height), dimensionType: model.dimensionType || "CM", - isMeasurement: model.measurements.length > 0, - isConfigurePromotionPrice: model.hasPromotion, - promotionPrice: model.hasPromotion ? toNumber(model.promotionPrice) : 0, - promotionCurrency: model.promotionCurrency || model.currency, - promotionStartDate: model.promotionStartDate || undefined, - promotionEndDate: model.promotionEndDate || undefined, - packagingWeight: toNumber(model.packagingWeight), + isMeasurement: model.hasMeasurements, + isConfigurePromotionPrice: model.hasMeasurements ? false : model.hasPromotion, + promotionPrice: model.hasMeasurements ? 0 : model.hasPromotion ? toNumber(model.promotionPrice) : 0, + promotionCurrency: model.hasMeasurements ? model.currency : model.promotionCurrency || model.currency, + promotionStartDate: model.hasMeasurements ? undefined : model.promotionStartDate || undefined, + promotionEndDate: model.hasMeasurements ? undefined : model.promotionEndDate || undefined, + packagingWeight: model.hasMeasurements ? 0 : toNumber(model.packagingWeight), packagingWeightType: model.packagingWeightType || "G", - packagingLength: toNumber(model.packagingLength), - packagingWidth: toNumber(model.packagingWidth), - packagingHeight: toNumber(model.packagingHeight), + packagingLength: model.hasMeasurements ? 0 : toNumber(model.packagingLength), + packagingWidth: model.hasMeasurements ? 0 : toNumber(model.packagingWidth), + packagingHeight: model.hasMeasurements ? 0 : toNumber(model.packagingHeight), packagingDimensionType: model.packagingDimensionType || "CM", - warehouses: model.warehouses - .filter((w) => w.id) - .map((w) => ({ id: w.id, stock: Number(w.stock || 0) })), - productMeasurements: model.measurements.map((m) => ({ - measurementType: m.measurementType, - measurementValue: m.measurementValue, - price: toNumber(m.price), - currency: m.currency, - weight: toNumber(m.weight), - weightType: m.weightType || "G", - length: toNumber(m.length), - width: toNumber(m.width), - height: toNumber(m.height), - dimensionType: m.dimensionType || "CM", - isConfigurePromotionPrice: m.hasPromotion, - promotionPrice: m.hasPromotion ? toNumber(m.promotionPrice) : 0, - promotionCurrency: m.promotionCurrency || m.currency, - promotionStartDate: m.promotionStartDate || undefined, - promotionEndDate: m.promotionEndDate || undefined, - packagingWeight: toNumber(m.packagingWeight), - packagingWeightType: m.packagingWeightType || "G", - packagingLength: toNumber(m.packagingLength), - packagingWidth: toNumber(m.packagingWidth), - packagingHeight: toNumber(m.packagingHeight), - packagingDimensionType: m.packagingDimensionType || "CM", - warehouses: m.warehouses - .filter((w) => w.id) - .map((w) => ({ id: w.id, stock: Number(w.stock || 0) })), - })), + warehouses: model.hasMeasurements + ? [] + : model.warehouses + .filter((w) => w.id) + .map((w) => ({ id: w.id, stock: Number(w.stock || 0) })), + productMeasurements: model.hasMeasurements + ? model.measurements.map((m) => ({ + measurementType: m.measurementType, + measurementValue: m.measurementValue, + price: toNumber(m.price), + currency: m.currency, + weight: toNumber(m.weight), + weightType: m.weightType || "G", + length: toNumber(m.length), + width: toNumber(m.width), + height: toNumber(m.height), + dimensionType: m.dimensionType || "CM", + isConfigurePromotionPrice: m.hasPromotion, + promotionPrice: m.hasPromotion ? toNumber(m.promotionPrice) : 0, + promotionCurrency: m.promotionCurrency || m.currency, + promotionStartDate: m.promotionStartDate || undefined, + promotionEndDate: m.promotionEndDate || undefined, + packagingWeight: toNumber(m.packagingWeight), + packagingWeightType: m.packagingWeightType || "G", + packagingLength: toNumber(m.packagingLength), + packagingWidth: toNumber(m.packagingWidth), + packagingHeight: toNumber(m.packagingHeight), + packagingDimensionType: m.packagingDimensionType || "CM", + warehouses: m.warehouses + .filter((w) => w.id) + .map((w) => ({ id: w.id, stock: Number(w.stock || 0) })), + })) + : [], })), productInformations: draft.productInformations.filter( (i) => i.paramName && i.paramValue