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