Fix product actions and warehouse onboarding
This commit is contained in:
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user