Update product review, measurement, and backend logging flows
This commit is contained in:
@ -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>
|
||||
))}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
5
src/instrumentation.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { registerBackendFetchLogger } from "@/lib/backend-fetch-logger";
|
||||
|
||||
export async function register() {
|
||||
registerBackendFetchLogger();
|
||||
}
|
||||
141
src/lib/backend-fetch-logger.ts
Normal file
141
src/lib/backend-fetch-logger.ts
Normal 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;
|
||||
}
|
||||
@ -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...",
|
||||
|
||||
@ -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...",
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user