Update product review, measurement, and backend logging flows

This commit is contained in:
2026-05-19 14:02:09 +07:00
parent 458ec4ca7e
commit e9a0cd0b2d
13 changed files with 1375 additions and 260 deletions

View File

@ -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<ProductDetail | null>(null);
const [resolvedMainCategoryName, setResolvedMainCategoryName] = useState("");
const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({});
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<string, string> = {};
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 (
<div className="w-full px-6 md:px-10 py-8 flex items-center justify-center py-32">
@ -226,7 +329,7 @@ function ProductDetailPageInner() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 rounded-xl bg-surface-container-low">
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-1">{d.mainCategory}</p>
<p className="text-sm font-semibold text-on-surface">{product.subCategory?.category?.name || "—"}</p>
<p className="text-sm font-semibold text-on-surface">{product.subCategory?.category?.name || resolvedMainCategoryName || "—"}</p>
</div>
<div className="p-4 rounded-xl bg-surface-container-low">
<p className="text-[10px] font-black uppercase tracking-widest text-outline mb-1">{d.subCategory}</p>
@ -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) => (
<div key={wi} className="flex justify-between text-xs text-on-surface-variant py-1 border-b border-surface-container last:border-0">
<span className="font-mono">{w.id?.slice(0, 8)}...</span>
<span>{warehouseMap[w.id] || w.name || `${w.id?.slice(0, 8)}...`}</span>
<span className="font-bold">{w.stock ?? 0} unit</span>
</div>
))}
@ -414,7 +517,7 @@ function ProductDetailPageInner() {
.filter((w: ProductWarehouse) => w.id)
.map((w: ProductWarehouse, wi: number) => (
<div key={wi} className="flex justify-between text-xs text-on-surface-variant py-0.5">
<span className="font-mono">{w.id?.slice(0, 8)}...</span>
<span>{warehouseMap[w.id || ""] || `${w.id?.slice(0, 8)}...`}</span>
<span className="font-bold">{w.stock ?? 0} unit</span>
</div>
))}

View File

@ -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 (
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm">
@ -812,7 +828,7 @@ function ModelCard({
<div className="flex items-center justify-between px-6 py-4 border-b border-surface-container rounded-t-2xl">
<div className="flex items-center gap-3">
<div className="w-7 h-7 rounded-full bg-primary flex items-center justify-center text-white text-xs font-black">{index + 1}</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 flex-wrap">
<input
value={model.name}
onChange={(ev) => 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"
/>
<div className="min-w-[220px] rounded-xl border border-primary/10 bg-primary/5 px-4 py-2.5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[10px] font-extrabold uppercase tracking-widest text-primary">
Product Measurement
</p>
<p className="mt-1 text-[11px] font-semibold text-on-surface-variant">
Gunakan measurement untuk harga, berat, dimensi, promo, dan stok.
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={model.hasMeasurements}
onChange={(ev) => toggleMeasurements(ev.target.checked)}
/>
<div className="h-6 w-11 rounded-full bg-outline/20 peer-checked:bg-primary peer-checked:after:translate-x-full after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all"></div>
</label>
</div>
</div>
</div>
</div>
{total > 1 && (
@ -845,16 +882,16 @@ function ModelCard({
</div>
{/* Pricing & Specs */}
<div className="space-y-4">
<div className={`space-y-4 ${disabledBlock}`}>
{/* Price row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] font-black uppercase tracking-widest text-outline mb-2">{e.price} <span className="text-error">*</span></label>
<input value={model.price} onChange={(ev) => set("price", ev.target.value)} placeholder="0" type="number" min="0" className={inp} />
<input value={model.price} onChange={(ev) => set("price", ev.target.value)} placeholder="0" type="number" min="0" className={inp} disabled={hasMeasurements} />
</div>
<div>
<label className="block text-[10px] font-black uppercase tracking-widest text-outline mb-2">{e.currency}</label>
<select value={model.currency} onChange={(ev) => set("currency", ev.target.value)} className={sel}>
<select value={model.currency} onChange={(ev) => set("currency", ev.target.value)} className={sel} disabled={hasMeasurements}>
{WORLD_CURRENCIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
@ -864,10 +901,10 @@ function ModelCard({
<div>
<label className="block text-[10px] font-black uppercase tracking-widest text-outline mb-2">{e.weight} <span className="text-error">*</span></label>
<div className="grid grid-cols-[1.5fr_1fr] gap-2">
<select value={model.weightType} onChange={(ev) => set("weightType", ev.target.value)} className={sel}>
<select value={model.weightType} onChange={(ev) => set("weightType", ev.target.value)} className={sel} disabled={hasMeasurements}>
{WEIGHT_TYPES.map((w) => <option key={w.value} value={w.value}>{w.label}</option>)}
</select>
<input value={model.weight} onChange={(ev) => set("weight", ev.target.value)} placeholder="0" type="number" min="0" className={inp} />
<input value={model.weight} onChange={(ev) => set("weight", ev.target.value)} placeholder="0" type="number" min="0" className={inp} disabled={hasMeasurements} />
</div>
</div>
@ -875,28 +912,38 @@ function ModelCard({
<div>
<label className="block text-[10px] font-black uppercase tracking-widest text-outline mb-2">{e.dims}</label>
<div className="grid grid-cols-[1.5fr_1fr_1fr_1fr] gap-2">
<select value={model.dimensionType} onChange={(ev) => set("dimensionType", ev.target.value)} className={sel}>
<select value={model.dimensionType} onChange={(ev) => set("dimensionType", ev.target.value)} className={sel} disabled={hasMeasurements}>
{DIMENSION_TYPES.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<input value={model.length} onChange={(ev) => set("length", ev.target.value)} placeholder="L" type="number" min="0" className={inp + " text-center"} />
<input value={model.width} onChange={(ev) => set("width", ev.target.value)} placeholder="W" type="number" min="0" className={inp + " text-center"} />
<input value={model.height} onChange={(ev) => set("height", ev.target.value)} placeholder="H" type="number" min="0" className={inp + " text-center"} />
<input value={model.length} onChange={(ev) => set("length", ev.target.value)} placeholder="L" type="number" min="0" className={inp + " text-center"} disabled={hasMeasurements} />
<input value={model.width} onChange={(ev) => set("width", ev.target.value)} placeholder="W" type="number" min="0" className={inp + " text-center"} disabled={hasMeasurements} />
<input value={model.height} onChange={(ev) => set("height", ev.target.value)} placeholder="H" type="number" min="0" className={inp + " text-center"} disabled={hasMeasurements} />
</div>
</div>
{hasMeasurements && (
<div className="rounded-xl border border-dashed border-primary/20 bg-primary/5 px-4 py-3 text-[11px] font-semibold text-on-surface-variant">
Field harga, berat, dan dimensi model dinonaktifkan karena mengikuti measurement.
</div>
)}
</div>
</div>
{/* Promotion & Packaging */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-6 border-t border-surface-container">
{/* Promotion */}
<div className="space-y-3">
<div className={`space-y-3 ${disabledBlock}`}>
<div className="flex items-center justify-between">
<span className="text-[10px] font-extrabold uppercase tracking-widest text-outline">{e.promotion}</span>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" checked={model.hasPromotion} onChange={(ev) => set("hasPromotion", ev.target.checked)} />
<input type="checkbox" className="sr-only peer" checked={model.hasPromotion} onChange={(ev) => set("hasPromotion", ev.target.checked)} disabled={hasMeasurements} />
<div className="w-8 h-4 bg-outline/20 rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[1px] after:left-[1px] after:bg-white after:rounded-full after:h-3.5 after:w-3.5 after:transition-all"></div>
</label>
</div>
{hasMeasurements && (
<p className="text-[11px] font-semibold text-on-surface-variant">
Promo model dinonaktifkan karena mengikuti measurement.
</p>
)}
<div className={`space-y-2 transition-opacity ${model.hasPromotion ? "" : "opacity-50 pointer-events-none"}`}>
<div className="grid grid-cols-2 gap-2">
<div>
@ -924,53 +971,64 @@ function ModelCard({
</div>
{/* Packaging */}
<div className="space-y-3">
<div className={`space-y-3 ${disabledBlock}`}>
<span className="text-[10px] font-extrabold uppercase tracking-widest text-outline block">{e.packagingFootprint}</span>
{hasMeasurements && (
<p className="text-[11px] font-semibold text-on-surface-variant">
Packaging model dinonaktifkan karena mengikuti measurement.
</p>
)}
<div>
<label className="text-[9px] font-bold block mb-1">{e.pkgWeight}</label>
<div className="grid grid-cols-[1.5fr_1fr] gap-2">
<select value={model.packagingWeightType} onChange={(ev) => set("packagingWeightType", ev.target.value)} className={sel}>
<select value={model.packagingWeightType} onChange={(ev) => set("packagingWeightType", ev.target.value)} className={sel} disabled={hasMeasurements}>
{WEIGHT_TYPES.map((w) => <option key={w.value} value={w.value}>{w.label}</option>)}
</select>
<input value={model.packagingWeight} onChange={(ev) => set("packagingWeight", ev.target.value)} placeholder="0" type="number" min="0" className={inp} />
<input value={model.packagingWeight} onChange={(ev) => set("packagingWeight", ev.target.value)} placeholder="0" type="number" min="0" className={inp} disabled={hasMeasurements} />
</div>
</div>
<div>
<label className="text-[9px] font-bold block mb-1">{e.pkgDimsShort}</label>
<div className="grid grid-cols-[1.5fr_1fr_1fr_1fr] gap-2">
<select value={model.packagingDimensionType} onChange={(ev) => set("packagingDimensionType", ev.target.value)} className={sel}>
<select value={model.packagingDimensionType} onChange={(ev) => set("packagingDimensionType", ev.target.value)} className={sel} disabled={hasMeasurements}>
{DIMENSION_TYPES.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
</select>
<input value={model.packagingLength} onChange={(ev) => set("packagingLength", ev.target.value)} placeholder="L" type="number" min="0" className={inp + " text-center"} />
<input value={model.packagingWidth} onChange={(ev) => set("packagingWidth", ev.target.value)} placeholder="W" type="number" min="0" className={inp + " text-center"} />
<input value={model.packagingHeight} onChange={(ev) => set("packagingHeight", ev.target.value)} placeholder="H" type="number" min="0" className={inp + " text-center"} />
<input value={model.packagingLength} onChange={(ev) => set("packagingLength", ev.target.value)} placeholder="L" type="number" min="0" className={inp + " text-center"} disabled={hasMeasurements} />
<input value={model.packagingWidth} onChange={(ev) => set("packagingWidth", ev.target.value)} placeholder="W" type="number" min="0" className={inp + " text-center"} disabled={hasMeasurements} />
<input value={model.packagingHeight} onChange={(ev) => set("packagingHeight", ev.target.value)} placeholder="H" type="number" min="0" className={inp + " text-center"} disabled={hasMeasurements} />
</div>
</div>
</div>
</div>
{/* Model Warehouses */}
<div className="pt-6 border-t border-surface-container">
<div className={`pt-6 border-t border-surface-container ${disabledBlock}`}>
<div className="flex items-center justify-between mb-3">
<label className="block text-[10px] font-black uppercase tracking-widest text-outline">
{e.warehouseStock} <span className="text-error">*</span>
</label>
<button type="button" onClick={() => onChange({ ...model, warehouses: [...model.warehouses, { id: "", stock: 0 }] })}
disabled={hasMeasurements}
className="text-[10px] font-black text-primary flex items-center gap-1">
<span className="material-symbols-outlined text-sm">add</span>{e.addWarehouse}
</button>
</div>
{hasMeasurements && (
<p className="mb-3 text-[11px] font-semibold text-on-surface-variant">
Stok model dinonaktifkan karena mengikuti measurement.
</p>
)}
<div className="space-y-2">
{model.warehouses.map((wh, wi) => (
<div key={wi} className="flex gap-2 items-center">
<select value={wh.id} onChange={(ev) => updateWh(wi, "id", ev.target.value)} 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">
<select value={wh.id} onChange={(ev) => 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">
<option value="">{e.selectWarehouse}</option>
{warehouses.map((w) => <option key={w.id} value={w.id}>{w.name || w.address} {w.city}</option>)}
</select>
<input value={wh.stock} onChange={(ev) => updateWh(wi, "stock", Number(ev.target.value || 0))} placeholder={e.stock} type="number" min="0"
<input value={wh.stock} onChange={(ev) => 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 && (
<button type="button" onClick={() => onChange({ ...model, warehouses: model.warehouses.filter((_, i) => i !== wi) })}
<button type="button" onClick={() => onChange({ ...model, warehouses: model.warehouses.filter((_, i) => i !== wi) })} disabled={hasMeasurements}
className="w-9 h-9 flex items-center justify-center rounded-lg text-error hover:bg-error-container transition-colors flex-shrink-0">
<span className="material-symbols-outlined text-base">close</span>
</button>
@ -981,6 +1039,7 @@ function ModelCard({
</div>
{/* Measurements */}
{hasMeasurements && (
<div className="pt-6 border-t border-surface-container">
<div className="flex items-center justify-between mb-4">
<div>
@ -1008,6 +1067,7 @@ function ModelCard({
</div>
)}
</div>
)}
</div>
</div>
);
@ -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),

View File

@ -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 (
<section className="mb-8">
@ -573,6 +585,27 @@ function ModelCard({
onChange={(e) => set("sku", e.target.value)}
/>
</div>
<div className="min-w-[220px] rounded-xl border border-red-100 bg-[#fff8f7] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-[10px] font-extrabold uppercase tracking-widest text-[#e53935]">
Product Measurement
</p>
<p className="mt-1 text-[11px] font-semibold text-[#6c757d]">
Use nested measurement rows for price, weight, dimension, and stock.
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={model.hasMeasurements}
onChange={(e) => toggleMeasurements(e.target.checked)}
/>
<div className="h-6 w-11 rounded-full bg-neutral-200 peer-checked:bg-[#e53935] peer-checked:after:translate-x-full after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all"></div>
</label>
</div>
</div>
</div>
</div>
{total > 1 && (
@ -601,7 +634,7 @@ function ModelCard({
</div>
{/* Pricing & Physical Specs */}
<div className="col-span-12 lg:col-span-8 grid grid-cols-2 gap-6">
<div className={`col-span-12 lg:col-span-8 grid grid-cols-2 gap-6 ${disabledBlock}`}>
<div className="col-span-1 space-y-2">
<label className="text-[11px] font-bold text-[#adb5bd] uppercase block">
Currency
@ -610,6 +643,7 @@ function ModelCard({
className={selGray}
value={model.currency}
onChange={(e) => set("currency", e.target.value)}
disabled={model.hasMeasurements}
>
{WORLD_CURRENCIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
@ -626,6 +660,7 @@ function ModelCard({
placeholder="0"
value={model.price}
onChange={(e) => set("price", e.target.value)}
disabled={model.hasMeasurements}
/>
</div>
<div className="col-span-1 space-y-2">
@ -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) => (
<option key={w.value} value={w.value}>{w.label}</option>
@ -653,6 +689,7 @@ function ModelCard({
placeholder="0"
value={model.weight}
onChange={(e) => set("weight", e.target.value)}
disabled={model.hasMeasurements}
/>
</div>
<div className="col-span-2 space-y-2">
@ -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) => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
<input className={inpGray + " text-center"} placeholder="L" type="number" min="0" value={model.length} onChange={(e) => set("length", e.target.value)} />
<input className={inpGray + " text-center"} placeholder="W" type="number" min="0" value={model.width} onChange={(e) => set("width", e.target.value)} />
<input className={inpGray + " text-center"} placeholder="H" type="number" min="0" value={model.height} onChange={(e) => set("height", e.target.value)} />
<input className={inpGray + " text-center"} placeholder="L" type="number" min="0" value={model.length} onChange={(e) => set("length", e.target.value)} disabled={model.hasMeasurements} />
<input className={inpGray + " text-center"} placeholder="W" type="number" min="0" value={model.width} onChange={(e) => set("width", e.target.value)} disabled={model.hasMeasurements} />
<input className={inpGray + " text-center"} placeholder="H" type="number" min="0" value={model.height} onChange={(e) => set("height", e.target.value)} disabled={model.hasMeasurements} />
</div>
</div>
{model.hasMeasurements && (
<div className="col-span-2 rounded-xl border border-dashed border-red-200 bg-[#fff8f7] px-4 py-3 text-[11px] font-semibold text-[#6c757d]">
Model-level currency, base price, weight, and dimensions are disabled while product measurement is enabled.
</div>
)}
</div>
</div>
{/* Promotions & Packaging */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 border-t border-neutral-100 pt-8">
{/* Promotions */}
<div className="space-y-4">
<div className={`space-y-4 ${disabledBlock}`}>
<div className="flex items-center justify-between">
<h3 className="text-sm font-extrabold uppercase tracking-wider flex items-center gap-2">
<span className="material-symbols-outlined text-[#e53935] text-[20px]">campaign</span>
@ -692,10 +735,16 @@ function ModelCard({
className="sr-only peer"
checked={model.hasPromotion}
onChange={(e) => set("hasPromotion", e.target.checked)}
disabled={model.hasMeasurements}
/>
<div className="w-10 h-5 bg-neutral-200 rounded-full peer peer-checked:bg-[#e53935] peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"></div>
</label>
</div>
{model.hasMeasurements && (
<p className="text-[11px] font-semibold text-[#6c757d]">
Promotion is managed per measurement when product measurement is enabled.
</p>
)}
<div
className={`grid grid-cols-2 gap-4 bg-[#fff8f7] p-4 rounded-xl border border-red-50 transition-opacity ${model.hasPromotion ? "" : "opacity-50 pointer-events-none"}`}
>
@ -753,11 +802,16 @@ function ModelCard({
</div>
{/* Packaging */}
<div className="space-y-4">
<div className={`space-y-4 ${disabledBlock}`}>
<h3 className="text-sm font-extrabold uppercase tracking-wider flex items-center gap-2">
<span className="material-symbols-outlined text-[#e53935] text-[20px]">package_2</span>
Packaging Footprint
</h3>
{model.hasMeasurements && (
<p className="text-[11px] font-semibold text-[#6c757d]">
Packaging values are managed per measurement when product measurement is enabled.
</p>
)}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-1">
<label className="text-[9px] font-bold text-[#adb5bd] uppercase block mb-1">
@ -767,6 +821,7 @@ function ModelCard({
className="w-full bg-[#f8f9fa] border-none rounded-lg p-2 text-[10px] font-bold focus:ring-1 focus:ring-[#e53935]/20 focus:outline-none"
value={model.packagingWeightType}
onChange={(e) => set("packagingWeightType", e.target.value)}
disabled={model.hasMeasurements}
>
{WEIGHT_TYPES.map((w) => (
<option key={w.value} value={w.value}>{w.label}</option>
@ -784,6 +839,7 @@ function ModelCard({
placeholder="0"
value={model.packagingWeight}
onChange={(e) => set("packagingWeight", e.target.value)}
disabled={model.hasMeasurements}
/>
</div>
<div className="col-span-2 grid grid-cols-[1.5fr_1fr_1fr_1fr] gap-2">
@ -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) => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
<input className="bg-[#f8f9fa] border-none rounded-lg p-2 text-xs font-bold text-center focus:ring-1 focus:ring-[#e53935]/20 focus:outline-none" placeholder="L" type="number" min="0" value={model.packagingLength} onChange={(e) => set("packagingLength", e.target.value)} />
<input className="bg-[#f8f9fa] border-none rounded-lg p-2 text-xs font-bold text-center focus:ring-1 focus:ring-[#e53935]/20 focus:outline-none" placeholder="W" type="number" min="0" value={model.packagingWidth} onChange={(e) => set("packagingWidth", e.target.value)} />
<input className="bg-[#f8f9fa] border-none rounded-lg p-2 text-xs font-bold text-center focus:ring-1 focus:ring-[#e53935]/20 focus:outline-none" placeholder="H" type="number" min="0" value={model.packagingHeight} onChange={(e) => set("packagingHeight", e.target.value)} />
<input className="bg-[#f8f9fa] border-none rounded-lg p-2 text-xs font-bold text-center focus:ring-1 focus:ring-[#e53935]/20 focus:outline-none" placeholder="L" type="number" min="0" value={model.packagingLength} onChange={(e) => set("packagingLength", e.target.value)} disabled={model.hasMeasurements} />
<input className="bg-[#f8f9fa] border-none rounded-lg p-2 text-xs font-bold text-center focus:ring-1 focus:ring-[#e53935]/20 focus:outline-none" placeholder="W" type="number" min="0" value={model.packagingWidth} onChange={(e) => set("packagingWidth", e.target.value)} disabled={model.hasMeasurements} />
<input className="bg-[#f8f9fa] border-none rounded-lg p-2 text-xs font-bold text-center focus:ring-1 focus:ring-[#e53935]/20 focus:outline-none" placeholder="H" type="number" min="0" value={model.packagingHeight} onChange={(e) => set("packagingHeight", e.target.value)} disabled={model.hasMeasurements} />
</div>
</div>
</div>
</div>
{/* Warehouse Stock Allocation */}
<div className="bg-neutral-50 rounded-xl p-6 border border-neutral-100">
<div className={`bg-neutral-50 rounded-xl p-6 border border-neutral-100 ${disabledBlock}`}>
<div className="flex justify-between items-center mb-4">
<h4 className="text-xs font-extrabold uppercase tracking-widest flex items-center gap-2 text-[#212529]">
<span className="material-symbols-outlined text-[18px]">warehouse</span>
@ -814,11 +871,17 @@ function ModelCard({
<button
type="button"
onClick={addWarehouse}
disabled={model.hasMeasurements}
className="text-[10px] font-bold text-[#e53935] flex items-center gap-1 bg-white px-3 py-1.5 rounded-lg border border-red-100 hover:bg-red-50 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">add</span> Add Warehouse
</button>
</div>
{model.hasMeasurements && (
<p className="mb-4 text-[11px] font-semibold text-[#6c757d]">
Stock allocation is managed per measurement when product measurement is enabled.
</p>
)}
<div className="space-y-3">
{model.warehouses.map((wh, whIndex) => (
<div key={whIndex} className="flex items-center gap-4 bg-white p-3 rounded-lg border border-neutral-200/50">
@ -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}
>
<option value="">Pilih gudang...</option>
{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}
/>
</div>
{model.warehouses.length > 1 && (
<button
type="button"
onClick={() => removeWarehouse(whIndex)}
disabled={model.hasMeasurements}
className="text-neutral-300 hover:text-red-500 transition-colors"
>
<span className="material-symbols-outlined text-[18px]">close</span>
@ -861,6 +927,7 @@ function ModelCard({
</div>
{/* Nested Measurements */}
{model.hasMeasurements && (
<div className="border-t border-dashed border-neutral-200 pt-10">
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg font-extrabold text-[#212529] flex items-center gap-2">
@ -900,6 +967,7 @@ function ModelCard({
</div>
)}
</div>
)}
</div>
</div>
</section>
@ -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");

View File

@ -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<Record<string, string>>({});
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() {
</div>
)}
<div className="mt-3 flex flex-wrap gap-3">
{draft.imageId && (
<div className="flex items-center gap-2 text-xs font-semibold text-on-surface-variant">
<span className="material-symbols-outlined text-primary text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>image</span>
{r.mainImage}
{reviewImageIds.length > 0 ? (
<div className="mt-4 space-y-3">
<div className="flex flex-wrap gap-3">
{reviewImageIds.map((imageId, index) => {
const url = imgUrl(imageId);
if (!url) return null;
const isMainImage = index === 0 && Boolean(draft.imageId);
return (
<div
key={`${imageId}-${index}`}
className="w-28 rounded-xl border border-outline-variant/10 bg-surface-container-low p-2"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={isMainImage ? r.mainImage : `${r.gallery} ${index}`}
className="h-20 w-full rounded-lg object-cover bg-surface-container"
/>
<p className="mt-2 text-[10px] font-black uppercase tracking-widest text-outline">
{isMainImage ? r.mainImage : `${r.gallery} ${draft.imageId ? index : index + 1}`}
</p>
</div>
);
})}
</div>
)}
{draft.productImages.filter(Boolean).length > 0 && (
<div className="flex items-center gap-2 text-xs font-semibold text-on-surface-variant">
<span className="material-symbols-outlined text-secondary text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>photo_library</span>
{draft.productImages.filter(Boolean).length} {r.gallery}
<div className="flex flex-wrap gap-3">
{draft.imageId && (
<div className="flex items-center gap-2 text-xs font-semibold text-on-surface-variant">
<span className="material-symbols-outlined text-primary text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>image</span>
{r.mainImage}
</div>
)}
{galleryImageIds.length > 0 && (
<div className="flex items-center gap-2 text-xs font-semibold text-on-surface-variant">
<span className="material-symbols-outlined text-secondary text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>photo_library</span>
{galleryImageIds.length} {r.gallery}
</div>
)}
</div>
)}
</div>
</div>
) : null}
</div>
{/* Pricing & Models */}
@ -187,9 +226,32 @@ export default function ProductReviewPage() {
{/* Model core info */}
<div className="grid grid-cols-2 gap-x-6">
<Row label={r.price} value={`${model.currency || "IDR"} ${formatIDR(model.price)}`} yes={r.yes} no={r.no} />
<Row label={`${r.weight} (${model.weightType || "G"})`} value={model.weight ? `${model.weight}` : undefined} yes={r.yes} no={r.no} />
<Row label={`${r.dimensions} (${model.dimensionType || "CM"})`} value={[model.length, model.width, model.height].filter(Boolean).join(" × ") || undefined} yes={r.yes} no={r.no} />
<Row
label={r.price}
value={
model.hasMeasurements
? "Using measurement variants"
: `${model.currency || "IDR"} ${formatIDR(model.price)}`
}
yes={r.yes}
no={r.no}
/>
<Row
label={`${r.weight} (${model.weightType || "G"})`}
value={model.hasMeasurements ? "Using measurement variants" : model.weight ? `${model.weight}` : undefined}
yes={r.yes}
no={r.no}
/>
<Row
label={`${r.dimensions} (${model.dimensionType || "CM"})`}
value={
model.hasMeasurements
? "Using measurement variants"
: [model.length, model.width, model.height].filter(Boolean).join(" × ") || undefined
}
yes={r.yes}
no={r.no}
/>
{model.hasPromotion && <Row label={r.promoPrice} value={`${model.promotionCurrency || model.currency || "IDR"} ${formatIDR(model.promotionPrice)}`} yes={r.yes} no={r.no} />}
{model.hasPromotion && model.promotionStartDate && (
<Row label={r.promoPeriod} value={`${model.promotionStartDate}${model.promotionEndDate}`} yes={r.yes} no={r.no} />
@ -199,7 +261,7 @@ export default function ProductReviewPage() {
</div>
{/* Warehouse stock */}
{model.warehouses.filter((w) => w.id).length > 0 && (
{!model.hasMeasurements && model.warehouses.filter((w) => w.id).length > 0 && (
<div>
<p className="text-[10px] text-outline font-bold uppercase tracking-widest mb-1">{r.warehouseStock}</p>
{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 && (
<div>
<p className="text-[10px] text-outline font-bold uppercase tracking-widest mb-2">
{r.measurements} ({model.measurements.length})

View File

@ -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<typeof useLanguage>["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<string, WarehouseLookup>
) {
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<string, WarehouseLookup>
) {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} />
@ -326,17 +505,75 @@ function StockPriceModal({
<p className="mt-0.5 text-xs text-outline">ID: {state.product.id}</p>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-3">
<label className="block">
<span className="mb-2 block text-xs font-black uppercase tracking-[0.18em] text-outline">
{d.model}
</span>
<select
value={state.productModelId}
onChange={(event) => onModelChange(event.target.value)}
disabled={state.loading || state.submitting}
className="w-full rounded-xl border border-outline-variant/20 bg-surface px-4 py-3 text-sm font-semibold text-on-surface outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{modelOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-black uppercase tracking-[0.18em] text-outline">
{d.measurement}
</span>
<select
value={state.productMeasurementId}
onChange={(event) => onMeasurementChange(event.target.value)}
disabled={state.loading || state.submitting || measurementOptions.length === 0}
className="w-full rounded-xl border border-outline-variant/20 bg-surface px-4 py-3 text-sm font-semibold text-on-surface outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{measurementOptions.length === 0 ? (
<option value="">{d.noMeasurement}</option>
) : (
measurementOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))
)}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-black uppercase tracking-[0.18em] text-outline">
{d.warehouse}
</span>
<select
value={state.warehouseId}
onChange={(event) => onWarehouseChange(event.target.value)}
disabled={state.loading || state.submitting}
className="w-full rounded-xl border border-outline-variant/20 bg-surface px-4 py-3 text-sm font-semibold text-on-surface outline-none transition-colors focus:border-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{warehouseOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-outline-variant/15 bg-surface-container-low p-4">
<p className="text-[10px] font-black uppercase tracking-widest text-outline">{d.currentPrice}</p>
<p className="mt-2 text-lg font-black text-on-surface">
{state.loading ? d.processing : formatCurrencyValue(state.currentPrice)}
{state.loading ? d.processing : formatCurrencyValue(selectedOption?.currentPrice || 0)}
</p>
</div>
<div className="rounded-xl border border-outline-variant/15 bg-surface-container-low p-4">
<p className="text-[10px] font-black uppercase tracking-widest text-outline">{d.currentStock}</p>
<p className="mt-2 text-lg font-black text-on-surface">
{state.loading ? d.processing : state.currentStock}
{state.loading ? d.processing : selectedOption?.currentStock || 0}
</p>
</div>
</div>
@ -434,6 +671,7 @@ function ProductsPageInner() {
const [publishingId, setPublishingId] = useState<string | null>(null);
const [restoringId, setRestoringId] = useState<string | null>(null);
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);
@ -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<string, WarehouseLookup>, 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}
</p>
<p className="text-[10px] font-medium text-outline">
ID: {product.id.slice(0, 8)}
</p>
<div className="mt-1 flex items-center gap-2 flex-wrap">
<p className="text-[10px] font-medium text-outline">
ID: {product.id.slice(0, 8)}
</p>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[9px] font-black uppercase tracking-tight ${productStateMeta.className}`}
>
{productStateMeta.label}
</span>
</div>
</div>
</div>
</td>
@ -1231,6 +1576,9 @@ function ProductsPageInner() {
setStockPriceTarget(null);
}
}}
onModelChange={handleStockPriceModelChange}
onMeasurementChange={handleStockPriceMeasurementChange}
onWarehouseChange={handleStockPriceWarehouseChange}
onPriceChange={(value) =>
setStockPriceTarget((current) =>
current ? { ...current, nextPrice: value, error: "" } : current

View File

@ -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 (
<div className={`rounded-lg border p-4 ${changed ? "border-amber-300 bg-amber-50/70" : accent ? "border-primary/20 bg-white" : "border-slate-100 bg-slate-50"}`}>
<div className="flex items-center gap-3 mb-3">
{imgUrl(model.imageId) && (
// eslint-disable-next-line @next/next/no-img-element
<img src={imgUrl(model.imageId)!} alt={model.name} className="w-12 h-12 object-cover rounded-lg border border-slate-100" />
)}
<div>
<p className="font-black text-sm text-on-surface">{model.name || `Model ${index + 1}`}</p>
{changed && (
<p className="mt-1 text-[10px] font-black uppercase tracking-widest text-amber-700">
Updated
</p>
)}
{hasMeasurements && (
<p className="mt-1 text-[10px] font-black uppercase tracking-widest text-primary">
{measurements.length} measurement variation(s)
</p>
)}
</div>
</div>
<Row label="SKU" value={model.sku} />
<Row label="Harga" value={hasMeasurements ? "Menggunakan harga measurement" : modelPrice} />
<Row label="Berat" value={hasMeasurements ? "Menggunakan berat measurement" : modelWeight} />
<Row label="Dimensi" value={hasMeasurements ? "Menggunakan dimensi measurement" : modelDimension} />
{model.isConfigurePromotionPrice && !hasMeasurements && (
<Row label="Harga Promo" value={modelPromotionPrice} />
)}
{!hasMeasurements && Array.isArray(model.warehouses) && model.warehouses.length > 0 && (
<div className="mt-3 pt-3 border-t border-slate-100">
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Warehouse & Stok</p>
{model.warehouses.map((warehouse: ReviewWarehouse, warehouseIndex: number) => (
<div key={warehouse.id || `warehouse-${warehouseIndex}`} className="flex justify-between text-sm py-1">
<span className="text-slate-500">{[warehouse.city, warehouse.province].filter(Boolean).join(", ")}</span>
<span className="font-black">{warehouse.stock ?? 0} unit</span>
</div>
))}
</div>
)}
{hasMeasurements && (
<div className="mt-4 space-y-3 border-t border-slate-100 pt-4">
{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 (
<div
key={`${measurement.measurementType || "measurement"}-${measurement.measurementValue || measurementIndex}`}
className={`rounded-lg border p-4 ${accent ? "border-primary/10 bg-primary/5" : "border-slate-200 bg-white/80"}`}
>
<p className="mb-3 text-xs font-black uppercase tracking-wider text-on-surface">
{formatMeasurementLabel(measurement, measurementIndex)}
</p>
<Row label="Harga" value={measurementPrice} />
<Row label="Berat" value={measurementWeight} />
<Row label="Dimensi" value={measurementDimension} />
{measurement.isConfigurePromotionPrice && (
<Row label="Harga Promo" value={measurementPromotionPrice} />
)}
{Array.isArray(measurement.warehouses) && measurement.warehouses.length > 0 && (
<div className="mt-3 pt-3 border-t border-slate-100">
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Warehouse & Stok</p>
{measurement.warehouses.map((warehouse: ReviewWarehouse, warehouseIndex: number) => (
<div key={warehouse.id || `warehouse-${warehouseIndex}`} className="flex justify-between text-sm py-1">
<span className="text-slate-500">{[warehouse.city, warehouse.province].filter(Boolean).join(", ")}</span>
<span className="font-black">{warehouse.stock ?? 0} unit</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
// ─── 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 (
<div className={`rounded-xl border shadow-sm p-5 ${accent ? "border-primary/30 bg-primary/5" : "bg-white border-slate-100"}`}>
<h3 className={`text-[10px] font-black uppercase tracking-[0.18em] mb-4 pb-3 border-b ${accent ? "text-primary border-primary/20" : "text-slate-400 border-slate-100"}`}>
{title}
</h3>
{children}
<div className={`rounded-xl border shadow-sm p-5 ${changed ? "border-amber-300 bg-amber-50/70" : accent ? "border-primary/30 bg-primary/5" : "bg-white border-slate-100"}`}>
<div className={`mb-4 flex items-center justify-between gap-3 pb-3 border-b ${changed ? "border-amber-200" : accent ? "border-primary/20" : "border-slate-100"}`}>
<h3 className={`text-[10px] font-black uppercase tracking-[0.18em] ${changed ? "text-amber-700" : accent ? "text-primary" : "text-slate-400"}`}>
{title}
</h3>
{changed && (
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-[9px] font-black uppercase tracking-widest text-amber-800">
Updated
</span>
)}
</div>
<div>
{children}
</div>
</div>
);
}
@ -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 (
<div className="flex-1 flex items-center justify-center py-20 text-slate-400 text-sm">Memuat data...</div>
);
@ -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 (
<div className="flex-1 min-w-0 space-y-4">
@ -100,11 +373,11 @@ function ProductColumn({ product, label, accent }: { product: any; label: string
</div>
{/* Images */}
{(images.length > 0 || imgUrl(product.imageId)) && (
<SectionCard title="Gambar Produk" accent={accent}>
{allImages.length > 0 && (
<SectionCard title="Gambar Produk" accent={accent} changed={hasChangesForPaths(compareRows, ["imageId", "image", "productImages"])}>
<div className="flex gap-2 flex-wrap">
{(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 */}
<SectionCard title="Detail Produk" accent={accent}>
<SectionCard
title="Detail Produk"
accent={accent}
changed={hasChangesForPaths(compareRows, [
"name",
"description",
"subCategory",
"isNew",
"isEligibleToExport",
"isPreOrder",
"preOrderDay",
"state",
])}
>
<Row label="Nama" value={product.name} />
<Row label="Deskripsi" value={product.description} />
<Row label="Kategori" value={product.subCategory?.category?.name} />
@ -130,7 +416,7 @@ function ProductColumn({ product, label, accent }: { product: any; label: string
{/* Features */}
{features.length > 0 && (
<SectionCard title="Fitur Produk" accent={accent}>
<SectionCard title="Fitur Produk" accent={accent} changed={hasChangesForPaths(compareRows, ["productFeatures"])}>
<ul className="space-y-1.5">
{features.map((f: string, i: number) => (
<li key={i} className="flex items-start gap-2 text-sm">
@ -144,7 +430,7 @@ function ProductColumn({ product, label, accent }: { product: any; label: string
{/* Keywords */}
{keywords.length > 0 && (
<SectionCard title="Keywords" accent={accent}>
<SectionCard title="Keywords" accent={accent} changed={hasChangesForPaths(compareRows, ["productKeyWords"])}>
<div className="flex flex-wrap gap-2">
{keywords.map((k: string) => (
<span key={k} className={`px-3 py-1 rounded-full text-xs font-bold ${accent ? "bg-primary/10 text-primary" : "bg-slate-100 text-slate-600"}`}>{k}</span>
@ -155,40 +441,16 @@ function ProductColumn({ product, label, accent }: { product: any; label: string
{/* Models */}
{models.length > 0 && (
<SectionCard title={`Model & Harga (${models.length})`} accent={accent}>
<SectionCard title={`Model & Harga (${models.length})`} accent={accent} changed={hasChangesForPaths(compareRows, ["productModels"])}>
<div className="space-y-3">
{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) => (
<div key={i} className={`rounded-lg border p-4 ${accent ? "border-primary/20 bg-white" : "border-slate-100 bg-slate-50"}`}>
<div className="flex items-center gap-3 mb-3">
{imgUrl(m.imageId) && (
// eslint-disable-next-line @next/next/no-img-element
<img src={imgUrl(m.imageId)!} alt={m.name} className="w-12 h-12 object-cover rounded-lg border border-slate-100" />
)}
<p className="font-black text-sm text-on-surface">{m.name || `Model ${i + 1}`}</p>
</div>
<Row label="SKU" value={m.sku} />
<Row label="Harga" value={m.price ? `${m.currency || "IDR"} ${Number(m.price).toLocaleString("id-ID")}` : undefined} />
<Row label="Berat" value={m.weight ? `${m.weight} ${m.weightType || ""}` : undefined} />
<Row label="Dimensi" value={[m.length, m.width, m.height].filter(Boolean).join(" × ") ? `${[m.length, m.width, m.height].filter(Boolean).join(" × ")} ${m.dimensionType || ""}` : undefined} />
{m.isConfigurePromotionPrice && <Row label="Harga Promo" value={m.promotionPrice ? `${m.currency || "IDR"} ${Number(m.promotionPrice).toLocaleString("id-ID")}` : undefined} />}
{Array.isArray(m.warehouses) && m.warehouses.length > 0 && (
<div className="mt-3 pt-3 border-t border-slate-100">
<p className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Warehouse & Stok</p>
{m.warehouses.map((w) => (
<div key={w.id} className="flex justify-between text-sm py-1">
<span className="text-slate-500">{[w.city, w.province].filter(Boolean).join(", ")}</span>
<span className="font-black">{w.stock ?? 0} unit</span>
</div>
))}
</div>
)}
</div>
{models.map((model: ReviewModel, index: number) => (
<ModelCard
key={`${model.sku || model.name || index}`}
model={model}
index={index}
accent={accent}
changed={hasChangesForPaths(compareRows, ["productModels"])}
/>
))}
</div>
</SectionCard>
@ -196,7 +458,7 @@ function ProductColumn({ product, label, accent }: { product: any; label: string
{/* Compliance */}
{product.complianceInformation && (
<SectionCard title="Compliance" accent={accent}>
<SectionCard title="Compliance" accent={accent} changed={hasChangesForPaths(compareRows, ["complianceInformation"])}>
<Row label="Negara Asal" value={product.complianceInformation.countryOfOrigin} />
<Row label="Safety Warning" value={product.complianceInformation.safetyWarning} />
<Row label="Dangerous Goods" value={product.complianceInformation.isDangerousGoodRegulation} />
@ -205,7 +467,7 @@ function ProductColumn({ product, label, accent }: { product: any; label: string
{/* Warranty */}
{product.warrantyInformation && (
<SectionCard title="Garansi" accent={accent}>
<SectionCard title="Garansi" accent={accent} changed={hasChangesForPaths(compareRows, ["warrantyInformation"])}>
<Row label="Tipe" value={product.warrantyInformation.type} />
<Row label="Durasi" value={product.warrantyInformation.duration ? `${product.warrantyInformation.duration} ${product.warrantyInformation.durationType}` : undefined} />
</SectionCard>
@ -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<any>(null); // updated (review)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [oldProduct, setOldProduct] = useState<any>(null); // original (compare)
const [product, setProduct] = useState<ReviewProductData | null>(null); // updated (review)
const [oldProduct, setOldProduct] = useState<ReviewProductData | null>(null); // original (compare)
const [isComparison, setIsComparison] = useState(false);
const [isUpdateProduct, setIsUpdateProduct] = useState(false);
const [compareRows, setCompareRows] = useState<CompareRow[]>([]);
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 ? (
<div className="flex gap-6 items-start">
<ProductColumn product={product} label="Versi Terbaru (Diajukan)" accent />
<ProductColumn product={product} label="Versi Terbaru (Diajukan)" accent compareRows={compareRows} />
<ProductColumn product={oldProduct} label="Versi Saat Ini (Live)" />
</div>
) : (

View File

@ -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 (
<div className="ml-12 mt-1 space-y-0.5">
<div className="relative z-10 ml-12 mt-1 space-y-0.5 pointer-events-auto">
{adminProductSubmenu.map((submenu) => {
const submenuTab = new URLSearchParams(
submenu.href.split("?")[1] || ""
@ -31,17 +31,18 @@ function AdminProductSubmenuNavInner() {
(isAllProduct ? currentTab === "" : submenuTab === currentTab);
return (
<Link
<button
key={submenu.href}
href={submenu.href}
className={`block rounded-lg px-4 py-2 text-sm font-semibold transition-colors ${
type="button"
onClick={() => 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}
</Link>
</button>
);
})}
</div>

View File

@ -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 (
<div className="ml-10 mt-1 space-y-0.5">
<div className="relative z-10 ml-10 mt-1 space-y-0.5 pointer-events-auto">
{productSubmenu.map((submenu) => {
const submenuTab = new URLSearchParams(
submenu.href.split("?")[1] || ""
@ -35,17 +35,18 @@ function ProductSubmenuNavInner() {
(isAllProduct ? currentTab === "" : submenuTab === currentTab);
return (
<Link
<button
key={submenu.href}
href={submenu.href}
className={`flex items-center py-2 pl-3 text-sm font-semibold transition-all rounded-r-xl ${
type="button"
onClick={() => 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}
</Link>
</button>
);
})}
</div>

5
src/instrumentation.ts Normal file
View File

@ -0,0 +1,5 @@
import { registerBackendFetchLogger } from "@/lib/backend-fetch-logger";
export async function register() {
registerBackendFetchLogger();
}

View File

@ -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<string, string> = {};
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;
}

View File

@ -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...",

View File

@ -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...",

View File

@ -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