Fix product actions and warehouse onboarding

This commit is contained in:
2026-05-26 14:03:53 +07:00
parent 8f05576464
commit 4c125a5326
4 changed files with 311 additions and 112 deletions

View File

@ -137,7 +137,7 @@ export function WarehouseForm({
postalCode: form.postalCode || null,
latitude: form.latitude ? parseFloat(form.latitude) : null,
longitude: form.longitude ? parseFloat(form.longitude) : null,
warehouseType: form.warehouseType || null,
warehouseType: "INA",
};
try {
@ -296,19 +296,6 @@ export function WarehouseForm({
/>
</div>
{/* Warehouse Type */}
<div>
<label className={labelCls}>Tipe Warehouse</label>
<select
value={form.warehouseType}
onChange={(e) => update({ warehouseType: e.target.value })}
className={inputCls}
>
<option value="INA">INA</option>
<option value="Other">Other</option>
</select>
</div>
{/* Lat / Lng */}
<div>
<label className={labelCls}>Latitude</label>

View File

@ -229,6 +229,19 @@ function toStr(v: string | number | null | undefined): string {
return String(v);
}
function hasBackendError(result: unknown): result is { responseCode?: string; responseDesc?: string; error?: string } {
if (!result || typeof result !== "object") return false;
const responseCode = "responseCode" in result ? String(result.responseCode ?? "") : "";
return Boolean(responseCode && responseCode !== "0000");
}
function backendErrorMessage(
result: { responseDesc?: string; error?: string } | null | undefined,
fallback: string
) {
return result?.responseDesc || result?.error || fallback;
}
function toProductFiles(items: ApiProduct["productFiles"]): Array<{ id: string; name: string }> {
if (!Array.isArray(items)) return [];
@ -1351,9 +1364,9 @@ function EditProductPageInner() {
body: JSON.stringify(payload),
});
const result = await res.json();
if (!res.ok) {
if (!res.ok || hasBackendError(result)) {
setErrorLog({ request: payload, response: result });
throw new Error(result?.responseDesc || "Gagal menyimpan draft");
throw new Error(backendErrorMessage(result, "Gagal menyimpan draft"));
}
setSaveSuccess(true);
@ -1382,9 +1395,9 @@ function EditProductPageInner() {
body: JSON.stringify(payload),
});
const result = await res.json();
if (!res.ok) {
if (!res.ok || hasBackendError(result)) {
setErrorLog({ request: payload, response: result });
throw new Error(result?.responseDesc || "Gagal mempublikasikan produk");
throw new Error(backendErrorMessage(result, "Gagal mempublikasikan produk"));
}
setPublishSuccess(true);
@ -1413,9 +1426,9 @@ function EditProductPageInner() {
body: JSON.stringify(payload),
});
const result = await res.json();
if (!res.ok) {
if (!res.ok || hasBackendError(result)) {
setErrorLog({ request: payload, response: result });
throw new Error(result?.responseDesc || "Gagal menyimpan produk");
throw new Error(backendErrorMessage(result, "Gagal menyimpan produk"));
}
setSaveSuccess(true);

View File

@ -2,7 +2,8 @@
import Image from "next/image";
import Link from "next/link";
import { Suspense, useEffect, useRef, useState } from "react";
import { Suspense, useEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useSearchParams } from "next/navigation";
import { useLanguage } from "@/lib/i18n-context";
import { getProductEffectivePoints } from "@/lib/product-variants";
@ -52,6 +53,8 @@ interface ProductMeasurementRef {
interface ProductModelRef {
id?: string | number | null;
productModelId?: string | number | null;
image?: string | null;
imageId?: string | null;
name?: string | null;
sku?: string | null;
isMeasurement?: boolean | null;
@ -61,6 +64,9 @@ interface ProductModelRef {
}
interface ProductDetailRef {
image?: string | null;
imageId?: string | null;
productImages?: Array<{ image?: string | null; imageId?: string | null; sequence?: number | null }> | null;
productModels?: ProductModelRef[];
}
@ -139,12 +145,33 @@ function hasMissingListPrice(product: ProductRow) {
return (!Number.isFinite(minPrice) || !Number.isFinite(maxPrice) || (minPrice <= 0 && maxPrice <= 0));
}
function hasMissingListImage(product: ProductRow) {
return !product.image;
}
function getDetailFallbackImage(detail: ProductDetailRef) {
if (detail.image) return detail.image;
const galleryImage = Array.isArray(detail.productImages)
? detail.productImages
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
.find((item) => item.image)?.image
: "";
if (galleryImage) return galleryImage;
return Array.isArray(detail.productModels)
? detail.productModels.find((model) => model.image)?.image || ""
: "";
}
async function hydrateRowsWithEffectivePrice(
rows: ProductRow[],
token: string,
query: string
) {
const targets = rows.filter(hasMissingListPrice);
const targets = rows.filter((row) => hasMissingListPrice(row) || hasMissingListImage(row));
if (targets.length === 0) return rows;
const details = await Promise.all(
@ -167,25 +194,32 @@ async function hydrateRowsWithEffectivePrice(
return rows.map((row) => {
const detail = detailMap.get(row.id);
if (!detail || !Array.isArray(detail.productModels)) return row;
if (!detail) return row;
const prices = getProductEffectivePoints(detail.productModels)
const fallbackImage = row.image || getDetailFallbackImage(detail);
const productModels = Array.isArray(detail.productModels) ? detail.productModels : row.productModels;
const prices = Array.isArray(productModels)
? getProductEffectivePoints(productModels)
.map((point) => point.price)
.filter((value): value is number => value !== undefined && value > 0)
.sort((a, b) => a - b);
.sort((a, b) => a - b)
: [];
if (prices.length === 0) {
return {
...row,
productModels: detail.productModels,
image: fallbackImage || row.image,
productModels,
};
}
return {
...row,
image: fallbackImage || row.image,
minPrice: prices[0],
maxPrice: prices[prices.length - 1],
productModels: detail.productModels,
productModels,
};
});
}
@ -747,7 +781,11 @@ function ProductsPageInner() {
const [stockPriceTarget, setStockPriceTarget] = useState<StockPriceTarget | null>(null);
const [warehouseLookupMap, setWarehouseLookupMap] = useState<Record<string, WarehouseLookup>>({});
const [openActionMenuId, setOpenActionMenuId] = useState<string | null>(null);
const actionMenuRef = useRef<HTMLDivElement | null>(null);
const [actionMenuPosition, setActionMenuPosition] = useState<{
left: number;
top?: number;
bottom?: number;
} | null>(null);
// Reset to page 1 when tab changes
useEffect(() => {
@ -760,27 +798,88 @@ function ProductsPageInner() {
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!actionMenuRef.current) return;
if (!actionMenuRef.current.contains(event.target as Node)) {
const target = event.target as HTMLElement | null;
if (!target?.closest("[data-product-action-menu]")) {
setOpenActionMenuId(null);
setActionMenuPosition(null);
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpenActionMenuId(null);
setActionMenuPosition(null);
}
}
function handleViewportChange() {
setOpenActionMenuId(null);
setActionMenuPosition(null);
}
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleEscape);
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleEscape);
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
};
}, []);
function closeActionMenu() {
setOpenActionMenuId(null);
setActionMenuPosition(null);
}
function toggleActionMenu(productId: string, event: React.MouseEvent<HTMLButtonElement>) {
if (openActionMenuId === productId) {
closeActionMenu();
return;
}
const rect = event.currentTarget.getBoundingClientRect();
const menuWidth = 192;
const estimatedMenuHeight = 240;
const viewportPadding = 12;
const shouldOpenUp = rect.bottom + estimatedMenuHeight > window.innerHeight;
setActionMenuPosition({
left: Math.max(
viewportPadding,
Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - viewportPadding)
),
...(shouldOpenUp
? { bottom: window.innerHeight - rect.top + 8 }
: { top: rect.bottom + 8 }),
});
setOpenActionMenuId(productId);
}
function renderActionMenu(productId: string, children: ReactNode) {
if (
openActionMenuId !== productId ||
!actionMenuPosition ||
typeof document === "undefined"
) {
return null;
}
return createPortal(
<div
data-product-action-menu
className="fixed z-[80] w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl"
style={actionMenuPosition}
>
{children}
</div>,
document.body
);
}
useEffect(() => {
async function loadWarehouses() {
try {
@ -1407,22 +1506,17 @@ function ProductsPageInner() {
{p.deletedByAdmin}
</span>
) : (
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
<div className="relative" data-product-action-menu>
<button
type="button"
onClick={() =>
setOpenActionMenuId((current) =>
current === product.id ? null : product.id
)
}
onClick={(event) => toggleActionMenu(product.id, event)}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-outline-variant/20 bg-surface-container-low text-on-surface transition-colors hover:border-primary/30 hover:text-primary"
aria-label={`Open actions for ${product.name}`}
aria-expanded={openActionMenuId === product.id}
>
<span className="material-symbols-outlined text-xl">more_vert</span>
</button>
{openActionMenuId === product.id ? (
<div className="absolute right-0 top-11 z-20 w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl">
{renderActionMenu(product.id, <>
<button
type="button"
onClick={() => handleRestore(product.id)}
@ -1432,27 +1526,21 @@ function ProductsPageInner() {
<span className="material-symbols-outlined text-lg text-tertiary">settings_backup_restore</span>
{restoringId === product.id ? p.publishing : p.restore}
</button>
</div>
) : null}
</>)}
</div>
)
) : productState === "UNPUBLISHED" ? (
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
<div className="relative" data-product-action-menu>
<button
type="button"
onClick={() =>
setOpenActionMenuId((current) =>
current === product.id ? null : product.id
)
}
onClick={(event) => toggleActionMenu(product.id, event)}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-outline-variant/20 bg-surface-container-low text-on-surface transition-colors hover:border-primary/30 hover:text-primary"
aria-label={`Open actions for ${product.name}`}
aria-expanded={openActionMenuId === product.id}
>
<span className="material-symbols-outlined text-xl">more_vert</span>
</button>
{openActionMenuId === product.id ? (
<div className="absolute right-0 top-11 z-20 w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl">
{renderActionMenu(product.id, <>
<button
type="button"
onClick={() => handlePublish(product.id)}
@ -1470,26 +1558,20 @@ function ProductsPageInner() {
<span className="material-symbols-outlined text-lg text-secondary">price_change</span>
{p.editStockPrice}
</button>
</div>
) : null}
</>)}
</div>
) : productState === "DELETED_BY_SELLER" ? (
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
<div className="relative" data-product-action-menu>
<button
type="button"
onClick={() =>
setOpenActionMenuId((current) =>
current === product.id ? null : product.id
)
}
onClick={(event) => toggleActionMenu(product.id, event)}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-outline-variant/20 bg-surface-container-low text-on-surface transition-colors hover:border-primary/30 hover:text-primary"
aria-label={`Open actions for ${product.name}`}
aria-expanded={openActionMenuId === product.id}
>
<span className="material-symbols-outlined text-xl">more_vert</span>
</button>
{openActionMenuId === product.id ? (
<div className="absolute right-0 top-11 z-20 w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl">
{renderActionMenu(product.id, <>
<button
type="button"
onClick={() => handleRestore(product.id)}
@ -1507,26 +1589,20 @@ function ProductsPageInner() {
<span className="material-symbols-outlined text-lg text-secondary">price_change</span>
{p.editStockPrice}
</button>
</div>
) : null}
</>)}
</div>
) : (
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
<div className="relative" data-product-action-menu>
<button
type="button"
onClick={() =>
setOpenActionMenuId((current) =>
current === product.id ? null : product.id
)
}
onClick={(event) => toggleActionMenu(product.id, event)}
className="flex h-9 w-9 items-center justify-center rounded-xl border border-outline-variant/20 bg-surface-container-low text-on-surface transition-colors hover:border-primary/30 hover:text-primary"
aria-label={`Open actions for ${product.name}`}
aria-expanded={openActionMenuId === product.id}
>
<span className="material-symbols-outlined text-xl">more_vert</span>
</button>
{openActionMenuId === product.id ? (
<div className="absolute right-0 top-11 z-20 w-48 overflow-hidden rounded-2xl border border-outline-variant/15 bg-surface-container-lowest py-2 shadow-2xl">
{renderActionMenu(product.id, <>
{!isInReviewTab ? (
!isDeletedTab ? (
<Link
@ -1579,8 +1655,7 @@ function ProductsPageInner() {
<span className="material-symbols-outlined text-lg">delete</span>
Delete
</button>
</div>
) : null}
</>)}
</div>
)}
</div>