Fix product actions and warehouse onboarding
This commit is contained in:
@ -137,7 +137,7 @@ export function WarehouseForm({
|
|||||||
postalCode: form.postalCode || null,
|
postalCode: form.postalCode || null,
|
||||||
latitude: form.latitude ? parseFloat(form.latitude) : null,
|
latitude: form.latitude ? parseFloat(form.latitude) : null,
|
||||||
longitude: form.longitude ? parseFloat(form.longitude) : null,
|
longitude: form.longitude ? parseFloat(form.longitude) : null,
|
||||||
warehouseType: form.warehouseType || null,
|
warehouseType: "INA",
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -296,19 +296,6 @@ export function WarehouseForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Lat / Lng */}
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>Latitude</label>
|
<label className={labelCls}>Latitude</label>
|
||||||
|
|||||||
@ -229,6 +229,19 @@ function toStr(v: string | number | null | undefined): string {
|
|||||||
return String(v);
|
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 }> {
|
function toProductFiles(items: ApiProduct["productFiles"]): Array<{ id: string; name: string }> {
|
||||||
if (!Array.isArray(items)) return [];
|
if (!Array.isArray(items)) return [];
|
||||||
|
|
||||||
@ -1351,9 +1364,9 @@ function EditProductPageInner() {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok || hasBackendError(result)) {
|
||||||
setErrorLog({ request: payload, response: result });
|
setErrorLog({ request: payload, response: result });
|
||||||
throw new Error(result?.responseDesc || "Gagal menyimpan draft");
|
throw new Error(backendErrorMessage(result, "Gagal menyimpan draft"));
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaveSuccess(true);
|
setSaveSuccess(true);
|
||||||
@ -1382,9 +1395,9 @@ function EditProductPageInner() {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok || hasBackendError(result)) {
|
||||||
setErrorLog({ request: payload, response: result });
|
setErrorLog({ request: payload, response: result });
|
||||||
throw new Error(result?.responseDesc || "Gagal mempublikasikan produk");
|
throw new Error(backendErrorMessage(result, "Gagal mempublikasikan produk"));
|
||||||
}
|
}
|
||||||
|
|
||||||
setPublishSuccess(true);
|
setPublishSuccess(true);
|
||||||
@ -1413,9 +1426,9 @@ function EditProductPageInner() {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok || hasBackendError(result)) {
|
||||||
setErrorLog({ request: payload, response: result });
|
setErrorLog({ request: payload, response: result });
|
||||||
throw new Error(result?.responseDesc || "Gagal menyimpan produk");
|
throw new Error(backendErrorMessage(result, "Gagal menyimpan produk"));
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaveSuccess(true);
|
setSaveSuccess(true);
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
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 { useSearchParams } from "next/navigation";
|
||||||
import { useLanguage } from "@/lib/i18n-context";
|
import { useLanguage } from "@/lib/i18n-context";
|
||||||
import { getProductEffectivePoints } from "@/lib/product-variants";
|
import { getProductEffectivePoints } from "@/lib/product-variants";
|
||||||
@ -52,6 +53,8 @@ interface ProductMeasurementRef {
|
|||||||
interface ProductModelRef {
|
interface ProductModelRef {
|
||||||
id?: string | number | null;
|
id?: string | number | null;
|
||||||
productModelId?: string | number | null;
|
productModelId?: string | number | null;
|
||||||
|
image?: string | null;
|
||||||
|
imageId?: string | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
sku?: string | null;
|
sku?: string | null;
|
||||||
isMeasurement?: boolean | null;
|
isMeasurement?: boolean | null;
|
||||||
@ -61,6 +64,9 @@ interface ProductModelRef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ProductDetailRef {
|
interface ProductDetailRef {
|
||||||
|
image?: string | null;
|
||||||
|
imageId?: string | null;
|
||||||
|
productImages?: Array<{ image?: string | null; imageId?: string | null; sequence?: number | null }> | null;
|
||||||
productModels?: ProductModelRef[];
|
productModels?: ProductModelRef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,12 +145,33 @@ function hasMissingListPrice(product: ProductRow) {
|
|||||||
return (!Number.isFinite(minPrice) || !Number.isFinite(maxPrice) || (minPrice <= 0 && maxPrice <= 0));
|
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(
|
async function hydrateRowsWithEffectivePrice(
|
||||||
rows: ProductRow[],
|
rows: ProductRow[],
|
||||||
token: string,
|
token: string,
|
||||||
query: string
|
query: string
|
||||||
) {
|
) {
|
||||||
const targets = rows.filter(hasMissingListPrice);
|
const targets = rows.filter((row) => hasMissingListPrice(row) || hasMissingListImage(row));
|
||||||
if (targets.length === 0) return rows;
|
if (targets.length === 0) return rows;
|
||||||
|
|
||||||
const details = await Promise.all(
|
const details = await Promise.all(
|
||||||
@ -167,25 +194,32 @@ async function hydrateRowsWithEffectivePrice(
|
|||||||
|
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
const detail = detailMap.get(row.id);
|
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)
|
.map((point) => point.price)
|
||||||
.filter((value): value is number => value !== undefined && value > 0)
|
.filter((value): value is number => value !== undefined && value > 0)
|
||||||
.sort((a, b) => a - b);
|
.sort((a, b) => a - b)
|
||||||
|
: [];
|
||||||
|
|
||||||
if (prices.length === 0) {
|
if (prices.length === 0) {
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
productModels: detail.productModels,
|
image: fallbackImage || row.image,
|
||||||
|
productModels,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
|
image: fallbackImage || row.image,
|
||||||
minPrice: prices[0],
|
minPrice: prices[0],
|
||||||
maxPrice: prices[prices.length - 1],
|
maxPrice: prices[prices.length - 1],
|
||||||
productModels: detail.productModels,
|
productModels,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -747,7 +781,11 @@ function ProductsPageInner() {
|
|||||||
const [stockPriceTarget, setStockPriceTarget] = useState<StockPriceTarget | null>(null);
|
const [stockPriceTarget, setStockPriceTarget] = useState<StockPriceTarget | null>(null);
|
||||||
const [warehouseLookupMap, setWarehouseLookupMap] = useState<Record<string, WarehouseLookup>>({});
|
const [warehouseLookupMap, setWarehouseLookupMap] = useState<Record<string, WarehouseLookup>>({});
|
||||||
const [openActionMenuId, setOpenActionMenuId] = useState<string | null>(null);
|
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
|
// Reset to page 1 when tab changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -760,27 +798,88 @@ function ProductsPageInner() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handlePointerDown(event: MouseEvent) {
|
function handlePointerDown(event: MouseEvent) {
|
||||||
if (!actionMenuRef.current) return;
|
const target = event.target as HTMLElement | null;
|
||||||
if (!actionMenuRef.current.contains(event.target as Node)) {
|
if (!target?.closest("[data-product-action-menu]")) {
|
||||||
setOpenActionMenuId(null);
|
setOpenActionMenuId(null);
|
||||||
|
setActionMenuPosition(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEscape(event: KeyboardEvent) {
|
function handleEscape(event: KeyboardEvent) {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
setOpenActionMenuId(null);
|
setOpenActionMenuId(null);
|
||||||
|
setActionMenuPosition(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleViewportChange() {
|
||||||
|
setOpenActionMenuId(null);
|
||||||
|
setActionMenuPosition(null);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("mousedown", handlePointerDown);
|
document.addEventListener("mousedown", handlePointerDown);
|
||||||
document.addEventListener("keydown", handleEscape);
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
window.addEventListener("resize", handleViewportChange);
|
||||||
|
window.addEventListener("scroll", handleViewportChange, true);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handlePointerDown);
|
document.removeEventListener("mousedown", handlePointerDown);
|
||||||
document.removeEventListener("keydown", handleEscape);
|
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(() => {
|
useEffect(() => {
|
||||||
async function loadWarehouses() {
|
async function loadWarehouses() {
|
||||||
try {
|
try {
|
||||||
@ -1407,22 +1506,17 @@ function ProductsPageInner() {
|
|||||||
{p.deletedByAdmin}
|
{p.deletedByAdmin}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
|
<div className="relative" data-product-action-menu>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={(event) => toggleActionMenu(product.id, event)}
|
||||||
setOpenActionMenuId((current) =>
|
|
||||||
current === product.id ? null : product.id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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"
|
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-label={`Open actions for ${product.name}`}
|
||||||
aria-expanded={openActionMenuId === product.id}
|
aria-expanded={openActionMenuId === product.id}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-xl">more_vert</span>
|
<span className="material-symbols-outlined text-xl">more_vert</span>
|
||||||
</button>
|
</button>
|
||||||
{openActionMenuId === product.id ? (
|
{renderActionMenu(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">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRestore(product.id)}
|
onClick={() => handleRestore(product.id)}
|
||||||
@ -1432,27 +1526,21 @@ function ProductsPageInner() {
|
|||||||
<span className="material-symbols-outlined text-lg text-tertiary">settings_backup_restore</span>
|
<span className="material-symbols-outlined text-lg text-tertiary">settings_backup_restore</span>
|
||||||
{restoringId === product.id ? p.publishing : p.restore}
|
{restoringId === product.id ? p.publishing : p.restore}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>)}
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : productState === "UNPUBLISHED" ? (
|
) : productState === "UNPUBLISHED" ? (
|
||||||
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
|
<div className="relative" data-product-action-menu>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={(event) => toggleActionMenu(product.id, event)}
|
||||||
setOpenActionMenuId((current) =>
|
|
||||||
current === product.id ? null : product.id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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"
|
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-label={`Open actions for ${product.name}`}
|
||||||
aria-expanded={openActionMenuId === product.id}
|
aria-expanded={openActionMenuId === product.id}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-xl">more_vert</span>
|
<span className="material-symbols-outlined text-xl">more_vert</span>
|
||||||
</button>
|
</button>
|
||||||
{openActionMenuId === product.id ? (
|
{renderActionMenu(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">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handlePublish(product.id)}
|
onClick={() => handlePublish(product.id)}
|
||||||
@ -1470,26 +1558,20 @@ function ProductsPageInner() {
|
|||||||
<span className="material-symbols-outlined text-lg text-secondary">price_change</span>
|
<span className="material-symbols-outlined text-lg text-secondary">price_change</span>
|
||||||
{p.editStockPrice}
|
{p.editStockPrice}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>)}
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : productState === "DELETED_BY_SELLER" ? (
|
) : productState === "DELETED_BY_SELLER" ? (
|
||||||
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
|
<div className="relative" data-product-action-menu>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={(event) => toggleActionMenu(product.id, event)}
|
||||||
setOpenActionMenuId((current) =>
|
|
||||||
current === product.id ? null : product.id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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"
|
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-label={`Open actions for ${product.name}`}
|
||||||
aria-expanded={openActionMenuId === product.id}
|
aria-expanded={openActionMenuId === product.id}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-xl">more_vert</span>
|
<span className="material-symbols-outlined text-xl">more_vert</span>
|
||||||
</button>
|
</button>
|
||||||
{openActionMenuId === product.id ? (
|
{renderActionMenu(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">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRestore(product.id)}
|
onClick={() => handleRestore(product.id)}
|
||||||
@ -1507,26 +1589,20 @@ function ProductsPageInner() {
|
|||||||
<span className="material-symbols-outlined text-lg text-secondary">price_change</span>
|
<span className="material-symbols-outlined text-lg text-secondary">price_change</span>
|
||||||
{p.editStockPrice}
|
{p.editStockPrice}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>)}
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative" ref={openActionMenuId === product.id ? actionMenuRef : null}>
|
<div className="relative" data-product-action-menu>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={(event) => toggleActionMenu(product.id, event)}
|
||||||
setOpenActionMenuId((current) =>
|
|
||||||
current === product.id ? null : product.id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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"
|
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-label={`Open actions for ${product.name}`}
|
||||||
aria-expanded={openActionMenuId === product.id}
|
aria-expanded={openActionMenuId === product.id}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-xl">more_vert</span>
|
<span className="material-symbols-outlined text-xl">more_vert</span>
|
||||||
</button>
|
</button>
|
||||||
{openActionMenuId === product.id ? (
|
{renderActionMenu(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">
|
|
||||||
{!isInReviewTab ? (
|
{!isInReviewTab ? (
|
||||||
!isDeletedTab ? (
|
!isDeletedTab ? (
|
||||||
<Link
|
<Link
|
||||||
@ -1579,8 +1655,7 @@ function ProductsPageInner() {
|
|||||||
<span className="material-symbols-outlined text-lg">delete</span>
|
<span className="material-symbols-outlined text-lg">delete</span>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>)}
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { UploadField } from "@/components/upload-field";
|
import { UploadField } from "@/components/upload-field";
|
||||||
|
import { COUNTRIES } from "@/lib/countries";
|
||||||
import { useLanguage } from "@/lib/i18n-context";
|
import { useLanguage } from "@/lib/i18n-context";
|
||||||
|
|
||||||
const ONBOARDING_BUSINESS_STORAGE_KEY = "onboardingBusinessDraft";
|
const ONBOARDING_BUSINESS_STORAGE_KEY = "onboardingBusinessDraft";
|
||||||
@ -34,6 +35,14 @@ type WarehouseListRow = {
|
|||||||
warehouseType?: string | null;
|
warehouseType?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Province = { id: string; name: string };
|
||||||
|
type City = { id: string; name: string };
|
||||||
|
|
||||||
|
function normalizeToken(token: string) {
|
||||||
|
if (!token) return "";
|
||||||
|
return token.startsWith("Bearer ") ? token : `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function StoreDetailPage() {
|
export default function StoreDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
@ -54,11 +63,19 @@ export default function StoreDetailPage() {
|
|||||||
warehouseType: "INA",
|
warehouseType: "INA",
|
||||||
country: "Indonesia",
|
country: "Indonesia",
|
||||||
province: "",
|
province: "",
|
||||||
|
provinceId: "",
|
||||||
city: "",
|
city: "",
|
||||||
|
cityId: "",
|
||||||
postalCode: "",
|
postalCode: "",
|
||||||
latitude: "",
|
latitude: "",
|
||||||
longitude: "",
|
longitude: "",
|
||||||
});
|
});
|
||||||
|
const [provinces, setProvinces] = useState<Province[]>([]);
|
||||||
|
const [cities, setCities] = useState<City[]>([]);
|
||||||
|
const [loadingProvinces, setLoadingProvinces] = useState(false);
|
||||||
|
const [loadingCities, setLoadingCities] = useState(false);
|
||||||
|
|
||||||
|
const isIndonesia = warehouse.country === "Indonesia";
|
||||||
|
|
||||||
function getToken() {
|
function getToken() {
|
||||||
return (
|
return (
|
||||||
@ -66,6 +83,10 @@ export default function StoreDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateWarehouse(patch: Partial<typeof warehouse>) {
|
||||||
|
setWarehouse((prev) => ({ ...prev, ...patch }));
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const businessDraftRaw = sessionStorage.getItem(ONBOARDING_BUSINESS_STORAGE_KEY);
|
const businessDraftRaw = sessionStorage.getItem(ONBOARDING_BUSINESS_STORAGE_KEY);
|
||||||
@ -124,6 +145,63 @@ export default function StoreDetailPage() {
|
|||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isIndonesia) {
|
||||||
|
setProvinces([]);
|
||||||
|
setCities([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingProvinces(true);
|
||||||
|
fetch("/api/locations/provinces", {
|
||||||
|
headers: { "x-auth-token": normalizeToken(getToken()) },
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
const rows = Array.isArray(data?.rows) ? data.rows : [];
|
||||||
|
setProvinces(rows);
|
||||||
|
|
||||||
|
setWarehouse((prev) => {
|
||||||
|
if (prev.provinceId || !prev.province) return prev;
|
||||||
|
const matched = rows.find(
|
||||||
|
(item: Province) =>
|
||||||
|
item.name.trim().toLowerCase() === prev.province.trim().toLowerCase()
|
||||||
|
);
|
||||||
|
return matched ? { ...prev, provinceId: matched.id } : prev;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => setProvinces([]))
|
||||||
|
.finally(() => setLoadingProvinces(false));
|
||||||
|
}, [isIndonesia]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isIndonesia || !warehouse.provinceId) {
|
||||||
|
setCities([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingCities(true);
|
||||||
|
fetch(`/api/locations/cities?provinceId=${warehouse.provinceId}`, {
|
||||||
|
headers: { "x-auth-token": normalizeToken(getToken()) },
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
const rows = Array.isArray(data?.rows) ? data.rows : [];
|
||||||
|
setCities(rows);
|
||||||
|
|
||||||
|
setWarehouse((prev) => {
|
||||||
|
if (prev.cityId || !prev.city) return prev;
|
||||||
|
const matched = rows.find(
|
||||||
|
(item: City) =>
|
||||||
|
item.name.trim().toLowerCase() === prev.city.trim().toLowerCase()
|
||||||
|
);
|
||||||
|
return matched ? { ...prev, cityId: matched.id } : prev;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => setCities([]))
|
||||||
|
.finally(() => setLoadingCities(false));
|
||||||
|
}, [isIndonesia, warehouse.provinceId]);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
@ -163,7 +241,7 @@ export default function StoreDetailPage() {
|
|||||||
postalCode: warehouse.postalCode.trim(),
|
postalCode: warehouse.postalCode.trim(),
|
||||||
latitude: toNumber(warehouse.latitude),
|
latitude: toNumber(warehouse.latitude),
|
||||||
longitude: toNumber(warehouse.longitude),
|
longitude: toNumber(warehouse.longitude),
|
||||||
warehouseType: warehouse.warehouseType,
|
warehouseType: "INA",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -394,9 +472,7 @@ export default function StoreDetailPage() {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={warehouse.name}
|
value={warehouse.name}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateWarehouse({ name: e.target.value })}
|
||||||
setWarehouse((prev) => ({ ...prev, name: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder={sd.warehouseNamePlaceholder}
|
placeholder={sd.warehouseNamePlaceholder}
|
||||||
className={headlineFieldClass}
|
className={headlineFieldClass}
|
||||||
/>
|
/>
|
||||||
@ -408,9 +484,7 @@ export default function StoreDetailPage() {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={warehouse.address}
|
value={warehouse.address}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateWarehouse({ address: e.target.value })}
|
||||||
setWarehouse((prev) => ({ ...prev, address: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder={sd.fullAddressPlaceholder}
|
placeholder={sd.fullAddressPlaceholder}
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
/>
|
/>
|
||||||
@ -420,39 +494,104 @@ export default function StoreDetailPage() {
|
|||||||
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||||
{sd.country}
|
{sd.country}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<select
|
||||||
value={warehouse.country}
|
value={warehouse.country}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setWarehouse((prev) => ({ ...prev, country: e.target.value }))
|
updateWarehouse({
|
||||||
|
country: e.target.value,
|
||||||
|
province: "",
|
||||||
|
provinceId: "",
|
||||||
|
city: "",
|
||||||
|
cityId: "",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
/>
|
>
|
||||||
|
<option value="">Pilih negara...</option>
|
||||||
|
{COUNTRIES.map((country) => (
|
||||||
|
<option key={country.code} value={country.name}>
|
||||||
|
{country.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||||
{sd.province}
|
{sd.province}
|
||||||
</label>
|
</label>
|
||||||
|
{isIndonesia ? (
|
||||||
|
<select
|
||||||
|
value={warehouse.provinceId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const selected = provinces.find((province) => province.id === e.target.value);
|
||||||
|
updateWarehouse({
|
||||||
|
provinceId: e.target.value,
|
||||||
|
province: selected?.name || "",
|
||||||
|
city: "",
|
||||||
|
cityId: "",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={loadingProvinces}
|
||||||
|
className={fieldClass}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{loadingProvinces ? "Memuat provinsi..." : "Pilih provinsi..."}
|
||||||
|
</option>
|
||||||
|
{provinces.map((province) => (
|
||||||
|
<option key={province.id} value={province.id}>
|
||||||
|
{province.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
<input
|
<input
|
||||||
value={warehouse.province}
|
value={warehouse.province}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateWarehouse({ province: e.target.value })}
|
||||||
setWarehouse((prev) => ({ ...prev, province: e.target.value }))
|
placeholder="Nama provinsi / state..."
|
||||||
}
|
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
<label className="mb-1 block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||||
{sd.city}
|
{sd.city}
|
||||||
</label>
|
</label>
|
||||||
|
{isIndonesia ? (
|
||||||
|
<select
|
||||||
|
value={warehouse.cityId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const selected = cities.find((city) => city.id === e.target.value);
|
||||||
|
updateWarehouse({
|
||||||
|
cityId: e.target.value,
|
||||||
|
city: selected?.name || "",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={!warehouse.provinceId || loadingCities}
|
||||||
|
className={fieldClass}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{!warehouse.provinceId
|
||||||
|
? "Pilih provinsi dulu..."
|
||||||
|
: loadingCities
|
||||||
|
? "Memuat kota..."
|
||||||
|
: "Pilih kota..."}
|
||||||
|
</option>
|
||||||
|
{cities.map((city) => (
|
||||||
|
<option key={city.id} value={city.id}>
|
||||||
|
{city.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
<input
|
<input
|
||||||
value={warehouse.city}
|
value={warehouse.city}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateWarehouse({ city: e.target.value })}
|
||||||
setWarehouse((prev) => ({ ...prev, city: e.target.value }))
|
placeholder="Nama kota..."
|
||||||
}
|
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -461,12 +600,7 @@ export default function StoreDetailPage() {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={warehouse.postalCode}
|
value={warehouse.postalCode}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateWarehouse({ postalCode: e.target.value })}
|
||||||
setWarehouse((prev) => ({
|
|
||||||
...prev,
|
|
||||||
postalCode: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -479,12 +613,7 @@ export default function StoreDetailPage() {
|
|||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
value={warehouse.latitude}
|
value={warehouse.latitude}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateWarehouse({ latitude: e.target.value })}
|
||||||
setWarehouse((prev) => ({
|
|
||||||
...prev,
|
|
||||||
latitude: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="5.548290"
|
placeholder="5.548290"
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
/>
|
/>
|
||||||
@ -498,12 +627,7 @@ export default function StoreDetailPage() {
|
|||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
value={warehouse.longitude}
|
value={warehouse.longitude}
|
||||||
onChange={(e) =>
|
onChange={(e) => updateWarehouse({ longitude: e.target.value })}
|
||||||
setWarehouse((prev) => ({
|
|
||||||
...prev,
|
|
||||||
longitude: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="95.323753"
|
placeholder="95.323753"
|
||||||
className={fieldClass}
|
className={fieldClass}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user