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
|
||||
|
||||
Reference in New Issue
Block a user