Update product review, measurement, and backend logging flows
This commit is contained in:
@ -12,6 +12,13 @@ interface ProductWarehouse {
|
|||||||
stock?: number;
|
stock?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WarehouseLookup {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
address?: string;
|
||||||
|
city?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProductMeasurement {
|
interface ProductMeasurement {
|
||||||
measurementType?: string;
|
measurementType?: string;
|
||||||
measurementValue?: string;
|
measurementValue?: string;
|
||||||
@ -44,6 +51,11 @@ interface ProductInfoItem {
|
|||||||
paramValue: string;
|
paramValue: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CategoryOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProductCategory {
|
interface ProductCategory {
|
||||||
name?: string;
|
name?: string;
|
||||||
}
|
}
|
||||||
@ -132,6 +144,8 @@ function ProductDetailPageInner() {
|
|||||||
const isDraft = searchParams.get("draft") === "1";
|
const isDraft = searchParams.get("draft") === "1";
|
||||||
const isReview = searchParams.get("review") === "1";
|
const isReview = searchParams.get("review") === "1";
|
||||||
const [product, setProduct] = useState<ProductDetail | null>(null);
|
const [product, setProduct] = useState<ProductDetail | null>(null);
|
||||||
|
const [resolvedMainCategoryName, setResolvedMainCategoryName] = useState("");
|
||||||
|
const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const errorLoadText = d.errorLoad;
|
const errorLoadText = d.errorLoad;
|
||||||
@ -153,6 +167,95 @@ function ProductDetailPageInner() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [errorLoadText, params.productId, isDraft, isReview]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-6 md:px-10 py-8 flex items-center justify-center py-32">
|
<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="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="p-4 rounded-xl bg-surface-container-low">
|
<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-[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>
|
||||||
<div className="p-4 rounded-xl bg-surface-container-low">
|
<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>
|
<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 */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
{m.warehouses.filter((w: any) => w.id).map((w: any, wi: number) => (
|
{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">
|
<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>
|
<span className="font-bold">{w.stock ?? 0} unit</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -414,7 +517,7 @@ function ProductDetailPageInner() {
|
|||||||
.filter((w: ProductWarehouse) => w.id)
|
.filter((w: ProductWarehouse) => w.id)
|
||||||
.map((w: ProductWarehouse, wi: number) => (
|
.map((w: ProductWarehouse, wi: number) => (
|
||||||
<div key={wi} className="flex justify-between text-xs text-on-surface-variant py-0.5">
|
<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>
|
<span className="font-bold">{w.stock ?? 0} unit</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -74,6 +74,7 @@ interface EditModel {
|
|||||||
promotionCurrency: string;
|
promotionCurrency: string;
|
||||||
promotionStartDate: string;
|
promotionStartDate: string;
|
||||||
promotionEndDate: string;
|
promotionEndDate: string;
|
||||||
|
hasMeasurements: boolean;
|
||||||
warehouses: EditWarehouse[];
|
warehouses: EditWarehouse[];
|
||||||
measurements: EditMeasurement[];
|
measurements: EditMeasurement[];
|
||||||
}
|
}
|
||||||
@ -145,6 +146,7 @@ interface ApiModel extends ApiMeasurement {
|
|||||||
sku?: string | number | null;
|
sku?: string | number | null;
|
||||||
imageId?: string | number | null;
|
imageId?: string | number | null;
|
||||||
image?: string | number | null;
|
image?: string | number | null;
|
||||||
|
isMeasurement?: boolean | null;
|
||||||
warehouses?: ApiWarehouse[];
|
warehouses?: ApiWarehouse[];
|
||||||
productMeasurements?: ApiMeasurement[];
|
productMeasurements?: ApiMeasurement[];
|
||||||
}
|
}
|
||||||
@ -324,6 +326,7 @@ function newModel(index: number): EditModel {
|
|||||||
promotionCurrency: "IDR",
|
promotionCurrency: "IDR",
|
||||||
promotionStartDate: "",
|
promotionStartDate: "",
|
||||||
promotionEndDate: "",
|
promotionEndDate: "",
|
||||||
|
hasMeasurements: false,
|
||||||
warehouses: [{ id: "", stock: 0 }],
|
warehouses: [{ id: "", stock: 0 }],
|
||||||
measurements: [],
|
measurements: [],
|
||||||
};
|
};
|
||||||
@ -357,6 +360,7 @@ function apiToEditState(data: ApiProduct): EditState {
|
|||||||
promotionCurrency: toStr(m.promotionCurrency) || toStr(m.currency) || "IDR",
|
promotionCurrency: toStr(m.promotionCurrency) || toStr(m.currency) || "IDR",
|
||||||
promotionStartDate: toStr(m.promotionStartDate),
|
promotionStartDate: toStr(m.promotionStartDate),
|
||||||
promotionEndDate: toStr(m.promotionEndDate),
|
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
|
warehouses: Array.isArray(m.warehouses) && m.warehouses.length > 0
|
||||||
? m.warehouses.map((w: ApiWarehouse) => ({
|
? m.warehouses.map((w: ApiWarehouse) => ({
|
||||||
id: toStr(w.id),
|
id: toStr(w.id),
|
||||||
@ -797,14 +801,26 @@ function ModelCard({
|
|||||||
onChange({ ...model, [field]: value });
|
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) {
|
function updateWh(wi: number, field: "id" | "stock", value: string | number) {
|
||||||
onChange({ ...model, warehouses: model.warehouses.map((w, i) => i === wi ? { ...w, [field]: value } : w) });
|
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 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 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 (
|
return (
|
||||||
<div className="bg-surface-container-lowest rounded-2xl border border-outline-variant/10 shadow-sm">
|
<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 justify-between px-6 py-4 border-b border-surface-container rounded-t-2xl">
|
||||||
<div className="flex items-center gap-3">
|
<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="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
|
<input
|
||||||
value={model.name}
|
value={model.name}
|
||||||
onChange={(ev) => set("name", ev.target.value)}
|
onChange={(ev) => set("name", ev.target.value)}
|
||||||
@ -825,6 +841,27 @@ function ModelCard({
|
|||||||
placeholder="SKU"
|
placeholder="SKU"
|
||||||
className="bg-transparent border-none text-xs font-bold text-outline focus:ring-0 p-0 placeholder-outline/30 w-32"
|
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>
|
||||||
</div>
|
</div>
|
||||||
{total > 1 && (
|
{total > 1 && (
|
||||||
@ -845,16 +882,16 @@ function ModelCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing & Specs */}
|
{/* Pricing & Specs */}
|
||||||
<div className="space-y-4">
|
<div className={`space-y-4 ${disabledBlock}`}>
|
||||||
{/* Price row */}
|
{/* Price row */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] font-black uppercase tracking-widest text-outline mb-2">{e.price} <span className="text-error">*</span></label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] font-black uppercase tracking-widest text-outline mb-2">{e.currency}</label>
|
<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>)}
|
{WORLD_CURRENCIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -864,10 +901,10 @@ function ModelCard({
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] font-black uppercase tracking-widest text-outline mb-2">{e.weight} <span className="text-error">*</span></label>
|
<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">
|
<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>)}
|
{WEIGHT_TYPES.map((w) => <option key={w.value} value={w.value}>{w.label}</option>)}
|
||||||
</select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -875,28 +912,38 @@ function ModelCard({
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] font-black uppercase tracking-widest text-outline mb-2">{e.dims}</label>
|
<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">
|
<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>)}
|
{DIMENSION_TYPES.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
|
||||||
</select>
|
</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.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"} />
|
<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"} />
|
<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>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Promotion & Packaging */}
|
{/* Promotion & Packaging */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-6 border-t border-surface-container">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-6 border-t border-surface-container">
|
||||||
{/* Promotion */}
|
{/* Promotion */}
|
||||||
<div className="space-y-3">
|
<div className={`space-y-3 ${disabledBlock}`}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[10px] font-extrabold uppercase tracking-widest text-outline">{e.promotion}</span>
|
<span className="text-[10px] font-extrabold uppercase tracking-widest text-outline">{e.promotion}</span>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<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>
|
<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>
|
</label>
|
||||||
</div>
|
</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={`space-y-2 transition-opacity ${model.hasPromotion ? "" : "opacity-50 pointer-events-none"}`}>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
@ -924,53 +971,64 @@ function ModelCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Packaging */}
|
{/* 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>
|
<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>
|
<div>
|
||||||
<label className="text-[9px] font-bold block mb-1">{e.pkgWeight}</label>
|
<label className="text-[9px] font-bold block mb-1">{e.pkgWeight}</label>
|
||||||
<div className="grid grid-cols-[1.5fr_1fr] gap-2">
|
<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>)}
|
{WEIGHT_TYPES.map((w) => <option key={w.value} value={w.value}>{w.label}</option>)}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[9px] font-bold block mb-1">{e.pkgDimsShort}</label>
|
<label className="text-[9px] font-bold block mb-1">{e.pkgDimsShort}</label>
|
||||||
<div className="grid grid-cols-[1.5fr_1fr_1fr_1fr] gap-2">
|
<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>)}
|
{DIMENSION_TYPES.map((d) => <option key={d.value} value={d.value}>{d.label}</option>)}
|
||||||
</select>
|
</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.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"} />
|
<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"} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Warehouses */}
|
{/* 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">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<label className="block text-[10px] font-black uppercase tracking-widest text-outline">
|
<label className="block text-[10px] font-black uppercase tracking-widest text-outline">
|
||||||
{e.warehouseStock} <span className="text-error">*</span>
|
{e.warehouseStock} <span className="text-error">*</span>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" onClick={() => onChange({ ...model, warehouses: [...model.warehouses, { id: "", stock: 0 }] })}
|
<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">
|
className="text-[10px] font-black text-primary flex items-center gap-1">
|
||||||
<span className="material-symbols-outlined text-sm">add</span>{e.addWarehouse}
|
<span className="material-symbols-outlined text-sm">add</span>{e.addWarehouse}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
{model.warehouses.map((wh, wi) => (
|
{model.warehouses.map((wh, wi) => (
|
||||||
<div key={wi} className="flex gap-2 items-center">
|
<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>
|
<option value="">{e.selectWarehouse}</option>
|
||||||
{warehouses.map((w) => <option key={w.id} value={w.id}>{w.name || w.address} — {w.city}</option>)}
|
{warehouses.map((w) => <option key={w.id} value={w.id}>{w.name || w.address} — {w.city}</option>)}
|
||||||
</select>
|
</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" />
|
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 && (
|
{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">
|
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>
|
<span className="material-symbols-outlined text-base">close</span>
|
||||||
</button>
|
</button>
|
||||||
@ -981,6 +1039,7 @@ function ModelCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Measurements */}
|
{/* Measurements */}
|
||||||
|
{hasMeasurements && (
|
||||||
<div className="pt-6 border-t border-surface-container">
|
<div className="pt-6 border-t border-surface-container">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
@ -1008,6 +1067,7 @@ function ModelCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -1219,28 +1279,28 @@ function EditProductPageInner() {
|
|||||||
name: m.name,
|
name: m.name,
|
||||||
sku: m.sku,
|
sku: m.sku,
|
||||||
imageId: m.imageId || undefined,
|
imageId: m.imageId || undefined,
|
||||||
price: toNum(m.price),
|
price: m.hasMeasurements ? 0 : toNum(m.price),
|
||||||
currency: m.currency,
|
currency: m.currency,
|
||||||
weight: toNum(m.weight),
|
weight: m.hasMeasurements ? 0 : toNum(m.weight),
|
||||||
weightType: m.weightType || "G",
|
weightType: m.weightType || "G",
|
||||||
length: toNum(m.length),
|
length: m.hasMeasurements ? 0 : toNum(m.length),
|
||||||
width: toNum(m.width),
|
width: m.hasMeasurements ? 0 : toNum(m.width),
|
||||||
height: toNum(m.height),
|
height: m.hasMeasurements ? 0 : toNum(m.height),
|
||||||
dimensionType: m.dimensionType || "CM",
|
dimensionType: m.dimensionType || "CM",
|
||||||
isMeasurement: m.measurements.length > 0,
|
isMeasurement: m.hasMeasurements,
|
||||||
isConfigurePromotionPrice: m.hasPromotion,
|
isConfigurePromotionPrice: m.hasMeasurements ? false : m.hasPromotion,
|
||||||
promotionPrice: m.hasPromotion ? toNum(m.promotionPrice) : null,
|
promotionPrice: m.hasMeasurements ? null : m.hasPromotion ? toNum(m.promotionPrice) : null,
|
||||||
promotionCurrency: m.hasPromotion ? (m.promotionCurrency || m.currency) : null,
|
promotionCurrency: m.hasMeasurements ? null : m.hasPromotion ? (m.promotionCurrency || m.currency) : null,
|
||||||
promotionStartDate: m.hasPromotion ? (m.promotionStartDate || null) : null,
|
promotionStartDate: m.hasMeasurements ? null : m.hasPromotion ? (m.promotionStartDate || null) : null,
|
||||||
promotionEndDate: m.hasPromotion ? (m.promotionEndDate || null) : null,
|
promotionEndDate: m.hasMeasurements ? null : m.hasPromotion ? (m.promotionEndDate || null) : null,
|
||||||
packagingWeight: toNum(m.packagingWeight),
|
packagingWeight: m.hasMeasurements ? 0 : toNum(m.packagingWeight),
|
||||||
packagingWeightType: m.packagingWeightType || "G",
|
packagingWeightType: m.packagingWeightType || "G",
|
||||||
packagingLength: toNum(m.packagingLength),
|
packagingLength: m.hasMeasurements ? 0 : toNum(m.packagingLength),
|
||||||
packagingWidth: toNum(m.packagingWidth),
|
packagingWidth: m.hasMeasurements ? 0 : toNum(m.packagingWidth),
|
||||||
packagingHeight: toNum(m.packagingHeight),
|
packagingHeight: m.hasMeasurements ? 0 : toNum(m.packagingHeight),
|
||||||
packagingDimensionType: m.packagingDimensionType || "CM",
|
packagingDimensionType: m.packagingDimensionType || "CM",
|
||||||
warehouses: m.warehouses.filter((w) => w.id).map((w) => ({ id: w.id, stock: Number(w.stock || 0) })),
|
warehouses: m.hasMeasurements ? [] : m.warehouses.filter((w) => w.id).map((w) => ({ id: w.id, stock: Number(w.stock || 0) })),
|
||||||
productMeasurements: m.measurements.map((ms) => ({
|
productMeasurements: m.hasMeasurements ? m.measurements.map((ms) => ({
|
||||||
measurementType: ms.measurementType,
|
measurementType: ms.measurementType,
|
||||||
measurementValue: ms.measurementValue,
|
measurementValue: ms.measurementValue,
|
||||||
price: toNum(ms.price),
|
price: toNum(ms.price),
|
||||||
@ -1263,7 +1323,7 @@ function EditProductPageInner() {
|
|||||||
promotionStartDate: ms.hasPromotion ? (ms.promotionStartDate || null) : null,
|
promotionStartDate: ms.hasPromotion ? (ms.promotionStartDate || null) : null,
|
||||||
promotionEndDate: ms.hasPromotion ? (ms.promotionEndDate || 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) })),
|
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),
|
productInformations: form.productInformations.filter((i) => i.paramName && i.paramValue),
|
||||||
categoryInformations: form.categoryInformations.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 });
|
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) {
|
function updateWarehouse(whIndex: number, field: "id" | "stock", value: string | number) {
|
||||||
const updated = model.warehouses.map((w, i) => {
|
const updated = model.warehouses.map((w, i) => {
|
||||||
if (i !== whIndex) return w;
|
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 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 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 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 (
|
return (
|
||||||
<section className="mb-8">
|
<section className="mb-8">
|
||||||
@ -573,6 +585,27 @@ function ModelCard({
|
|||||||
onChange={(e) => set("sku", e.target.value)}
|
onChange={(e) => set("sku", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{total > 1 && (
|
{total > 1 && (
|
||||||
@ -601,7 +634,7 @@ function ModelCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing & Physical Specs */}
|
{/* 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">
|
<div className="col-span-1 space-y-2">
|
||||||
<label className="text-[11px] font-bold text-[#adb5bd] uppercase block">
|
<label className="text-[11px] font-bold text-[#adb5bd] uppercase block">
|
||||||
Currency
|
Currency
|
||||||
@ -610,6 +643,7 @@ function ModelCard({
|
|||||||
className={selGray}
|
className={selGray}
|
||||||
value={model.currency}
|
value={model.currency}
|
||||||
onChange={(e) => set("currency", e.target.value)}
|
onChange={(e) => set("currency", e.target.value)}
|
||||||
|
disabled={model.hasMeasurements}
|
||||||
>
|
>
|
||||||
{WORLD_CURRENCIES.map((c) => (
|
{WORLD_CURRENCIES.map((c) => (
|
||||||
<option key={c.value} value={c.value}>{c.label}</option>
|
<option key={c.value} value={c.value}>{c.label}</option>
|
||||||
@ -626,6 +660,7 @@ function ModelCard({
|
|||||||
placeholder="0"
|
placeholder="0"
|
||||||
value={model.price}
|
value={model.price}
|
||||||
onChange={(e) => set("price", e.target.value)}
|
onChange={(e) => set("price", e.target.value)}
|
||||||
|
disabled={model.hasMeasurements}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1 space-y-2">
|
<div className="col-span-1 space-y-2">
|
||||||
@ -636,6 +671,7 @@ function ModelCard({
|
|||||||
className={selGray}
|
className={selGray}
|
||||||
value={model.weightType}
|
value={model.weightType}
|
||||||
onChange={(e) => set("weightType", e.target.value)}
|
onChange={(e) => set("weightType", e.target.value)}
|
||||||
|
disabled={model.hasMeasurements}
|
||||||
>
|
>
|
||||||
{WEIGHT_TYPES.map((w) => (
|
{WEIGHT_TYPES.map((w) => (
|
||||||
<option key={w.value} value={w.value}>{w.label}</option>
|
<option key={w.value} value={w.value}>{w.label}</option>
|
||||||
@ -653,6 +689,7 @@ function ModelCard({
|
|||||||
placeholder="0"
|
placeholder="0"
|
||||||
value={model.weight}
|
value={model.weight}
|
||||||
onChange={(e) => set("weight", e.target.value)}
|
onChange={(e) => set("weight", e.target.value)}
|
||||||
|
disabled={model.hasMeasurements}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 space-y-2">
|
<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"
|
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}
|
value={model.dimensionType}
|
||||||
onChange={(e) => set("dimensionType", e.target.value)}
|
onChange={(e) => set("dimensionType", e.target.value)}
|
||||||
|
disabled={model.hasMeasurements}
|
||||||
>
|
>
|
||||||
{DIMENSION_TYPES.map((d) => (
|
{DIMENSION_TYPES.map((d) => (
|
||||||
<option key={d.value} value={d.value}>{d.label}</option>
|
<option key={d.value} value={d.value}>{d.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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="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)} />
|
<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)} />
|
<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>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Promotions & Packaging */}
|
{/* Promotions & Packaging */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 border-t border-neutral-100 pt-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 border-t border-neutral-100 pt-8">
|
||||||
{/* Promotions */}
|
{/* Promotions */}
|
||||||
<div className="space-y-4">
|
<div className={`space-y-4 ${disabledBlock}`}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-extrabold uppercase tracking-wider flex items-center gap-2">
|
<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>
|
<span className="material-symbols-outlined text-[#e53935] text-[20px]">campaign</span>
|
||||||
@ -692,10 +735,16 @@ function ModelCard({
|
|||||||
className="sr-only peer"
|
className="sr-only peer"
|
||||||
checked={model.hasPromotion}
|
checked={model.hasPromotion}
|
||||||
onChange={(e) => set("hasPromotion", e.target.checked)}
|
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>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{model.hasMeasurements && (
|
||||||
|
<p className="text-[11px] font-semibold text-[#6c757d]">
|
||||||
|
Promotion is managed per measurement when product measurement is enabled.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div
|
<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"}`}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Packaging */}
|
{/* 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">
|
<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>
|
<span className="material-symbols-outlined text-[#e53935] text-[20px]">package_2</span>
|
||||||
Packaging Footprint
|
Packaging Footprint
|
||||||
</h3>
|
</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="grid grid-cols-2 gap-4">
|
||||||
<div className="col-span-1">
|
<div className="col-span-1">
|
||||||
<label className="text-[9px] font-bold text-[#adb5bd] uppercase block mb-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"
|
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}
|
value={model.packagingWeightType}
|
||||||
onChange={(e) => set("packagingWeightType", e.target.value)}
|
onChange={(e) => set("packagingWeightType", e.target.value)}
|
||||||
|
disabled={model.hasMeasurements}
|
||||||
>
|
>
|
||||||
{WEIGHT_TYPES.map((w) => (
|
{WEIGHT_TYPES.map((w) => (
|
||||||
<option key={w.value} value={w.value}>{w.label}</option>
|
<option key={w.value} value={w.value}>{w.label}</option>
|
||||||
@ -784,6 +839,7 @@ function ModelCard({
|
|||||||
placeholder="0"
|
placeholder="0"
|
||||||
value={model.packagingWeight}
|
value={model.packagingWeight}
|
||||||
onChange={(e) => set("packagingWeight", e.target.value)}
|
onChange={(e) => set("packagingWeight", e.target.value)}
|
||||||
|
disabled={model.hasMeasurements}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 grid grid-cols-[1.5fr_1fr_1fr_1fr] gap-2">
|
<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"
|
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}
|
value={model.packagingDimensionType}
|
||||||
onChange={(e) => set("packagingDimensionType", e.target.value)}
|
onChange={(e) => set("packagingDimensionType", e.target.value)}
|
||||||
|
disabled={model.hasMeasurements}
|
||||||
>
|
>
|
||||||
{DIMENSION_TYPES.map((d) => (
|
{DIMENSION_TYPES.map((d) => (
|
||||||
<option key={d.value} value={d.value}>{d.label}</option>
|
<option key={d.value} value={d.value}>{d.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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="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)} />
|
<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)} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Warehouse Stock Allocation */}
|
{/* 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">
|
<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]">
|
<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>
|
<span className="material-symbols-outlined text-[18px]">warehouse</span>
|
||||||
@ -814,11 +871,17 @@ function ModelCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addWarehouse}
|
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"
|
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
|
<span className="material-symbols-outlined text-[14px]">add</span> Add Warehouse
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="space-y-3">
|
||||||
{model.warehouses.map((wh, whIndex) => (
|
{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">
|
<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"
|
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}
|
value={wh.id}
|
||||||
onChange={(e) => updateWarehouse(whIndex, "id", e.target.value)}
|
onChange={(e) => updateWarehouse(whIndex, "id", e.target.value)}
|
||||||
|
disabled={model.hasMeasurements}
|
||||||
>
|
>
|
||||||
<option value="">Pilih gudang...</option>
|
<option value="">Pilih gudang...</option>
|
||||||
{warehouses.map((w) => (
|
{warehouses.map((w) => (
|
||||||
@ -844,12 +908,14 @@ function ModelCard({
|
|||||||
min="0"
|
min="0"
|
||||||
value={wh.stock}
|
value={wh.stock}
|
||||||
onChange={(e) => updateWarehouse(whIndex, "stock", Number(e.target.value || 0))}
|
onChange={(e) => updateWarehouse(whIndex, "stock", Number(e.target.value || 0))}
|
||||||
|
disabled={model.hasMeasurements}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{model.warehouses.length > 1 && (
|
{model.warehouses.length > 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeWarehouse(whIndex)}
|
onClick={() => removeWarehouse(whIndex)}
|
||||||
|
disabled={model.hasMeasurements}
|
||||||
className="text-neutral-300 hover:text-red-500 transition-colors"
|
className="text-neutral-300 hover:text-red-500 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||||
@ -861,6 +927,7 @@ function ModelCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nested Measurements */}
|
{/* Nested Measurements */}
|
||||||
|
{model.hasMeasurements && (
|
||||||
<div className="border-t border-dashed border-neutral-200 pt-10">
|
<div className="border-t border-dashed border-neutral-200 pt-10">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h3 className="text-lg font-extrabold text-[#212529] flex items-center gap-2">
|
<h3 className="text-lg font-extrabold text-[#212529] flex items-center gap-2">
|
||||||
@ -900,6 +967,7 @@ function ModelCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -971,18 +1039,49 @@ export default function ProductPricingPage() {
|
|||||||
setValidationError(`Model ${i + 1}: Nama model wajib diisi`);
|
setValidationError(`Model ${i + 1}: Nama model wajib diisi`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!m.price.trim()) {
|
if (m.hasMeasurements) {
|
||||||
setValidationError(`Model ${i + 1}: Harga wajib diisi`);
|
if (m.measurements.length < 1) {
|
||||||
return;
|
setValidationError(`Model ${i + 1}: Product measurement wajib diisi minimal 1 baris`);
|
||||||
}
|
return;
|
||||||
if (!m.weight.trim()) {
|
}
|
||||||
setValidationError(`Model ${i + 1}: Berat wajib diisi`);
|
for (let j = 0; j < m.measurements.length; j++) {
|
||||||
return;
|
const measurement = m.measurements[j];
|
||||||
}
|
if (!measurement.measurementType.trim()) {
|
||||||
const hasWarehouse = m.warehouses.some((w) => w.id);
|
setValidationError(`Model ${i + 1} Measurement ${j + 1}: Measurement type wajib diisi`);
|
||||||
if (!hasWarehouse) {
|
return;
|
||||||
setValidationError(`Model ${i + 1}: Pilih minimal satu gudang`);
|
}
|
||||||
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");
|
router.push("/products/new/specifications");
|
||||||
|
|||||||
@ -6,11 +6,19 @@ import { useProductDraft } from "@/lib/product-draft";
|
|||||||
import { useProductSubmit } from "@/lib/use-product-submit";
|
import { useProductSubmit } from "@/lib/use-product-submit";
|
||||||
import { useLanguage } from "@/lib/i18n-context";
|
import { useLanguage } from "@/lib/i18n-context";
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://be.inatrading.co.id";
|
||||||
|
|
||||||
function getToken() {
|
function getToken() {
|
||||||
if (typeof window === "undefined") return "";
|
if (typeof window === "undefined") return "";
|
||||||
return sessionStorage.getItem("token") || localStorage.getItem("token") || "";
|
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) {
|
function toNumber(value: string) {
|
||||||
const normalized = value.replace(/\./g, "").replace(/,/g, ".");
|
const normalized = value.replace(/\./g, "").replace(/,/g, ".");
|
||||||
const parsed = Number(normalized);
|
const parsed = Number(normalized);
|
||||||
@ -61,6 +69,10 @@ export default function ProductReviewPage() {
|
|||||||
const r = t.dashboard.productNew.review;
|
const r = t.dashboard.productNew.review;
|
||||||
const [errorLogCopied, setErrorLogCopied] = useState(false);
|
const [errorLogCopied, setErrorLogCopied] = useState(false);
|
||||||
const [warehouseMap, setWarehouseMap] = useState<Record<string, string>>({});
|
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(() => {
|
useEffect(() => {
|
||||||
async function loadWarehouses() {
|
async function loadWarehouses() {
|
||||||
@ -156,20 +168,47 @@ export default function ProductReviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-3">
|
{reviewImageIds.length > 0 ? (
|
||||||
{draft.imageId && (
|
<div className="mt-4 space-y-3">
|
||||||
<div className="flex items-center gap-2 text-xs font-semibold text-on-surface-variant">
|
<div className="flex flex-wrap gap-3">
|
||||||
<span className="material-symbols-outlined text-primary text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>image</span>
|
{reviewImageIds.map((imageId, index) => {
|
||||||
{r.mainImage}
|
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>
|
</div>
|
||||||
)}
|
<div className="flex flex-wrap gap-3">
|
||||||
{draft.productImages.filter(Boolean).length > 0 && (
|
{draft.imageId && (
|
||||||
<div className="flex items-center gap-2 text-xs font-semibold text-on-surface-variant">
|
<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>
|
<span className="material-symbols-outlined text-primary text-sm" style={{ fontVariationSettings: "'FILL' 1" }}>image</span>
|
||||||
{draft.productImages.filter(Boolean).length} {r.gallery}
|
{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>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing & Models */}
|
{/* Pricing & Models */}
|
||||||
@ -187,9 +226,32 @@ export default function ProductReviewPage() {
|
|||||||
|
|
||||||
{/* Model core info */}
|
{/* Model core info */}
|
||||||
<div className="grid grid-cols-2 gap-x-6">
|
<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
|
||||||
<Row label={`${r.weight} (${model.weightType || "G"})`} value={model.weight ? `${model.weight}` : undefined} yes={r.yes} no={r.no} />
|
label={r.price}
|
||||||
<Row label={`${r.dimensions} (${model.dimensionType || "CM"})`} value={[model.length, model.width, model.height].filter(Boolean).join(" × ") || undefined} yes={r.yes} no={r.no} />
|
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 && <Row label={r.promoPrice} value={`${model.promotionCurrency || model.currency || "IDR"} ${formatIDR(model.promotionPrice)}`} yes={r.yes} no={r.no} />}
|
||||||
{model.hasPromotion && model.promotionStartDate && (
|
{model.hasPromotion && model.promotionStartDate && (
|
||||||
<Row label={r.promoPeriod} value={`${model.promotionStartDate} → ${model.promotionEndDate}`} yes={r.yes} no={r.no} />
|
<Row label={r.promoPeriod} value={`${model.promotionStartDate} → ${model.promotionEndDate}`} yes={r.yes} no={r.no} />
|
||||||
@ -199,7 +261,7 @@ export default function ProductReviewPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Warehouse stock */}
|
{/* Warehouse stock */}
|
||||||
{model.warehouses.filter((w) => w.id).length > 0 && (
|
{!model.hasMeasurements && model.warehouses.filter((w) => w.id).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] text-outline font-bold uppercase tracking-widest mb-1">{r.warehouseStock}</p>
|
<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) => (
|
{model.warehouses.filter((w) => w.id).map((w, wi) => (
|
||||||
@ -212,7 +274,7 @@ export default function ProductReviewPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Measurements / Variants */}
|
{/* Measurements / Variants */}
|
||||||
{model.measurements.length > 0 && (
|
{model.hasMeasurements && model.measurements.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] text-outline font-bold uppercase tracking-widest mb-2">
|
<p className="text-[10px] text-outline font-bold uppercase tracking-widest mb-2">
|
||||||
{r.measurements} ({model.measurements.length})
|
{r.measurements} ({model.measurements.length})
|
||||||
|
|||||||
@ -41,6 +41,8 @@ interface ProductWarehouseRef {
|
|||||||
interface ProductMeasurementRef {
|
interface ProductMeasurementRef {
|
||||||
id?: string | number | null;
|
id?: string | number | null;
|
||||||
productMeasurementId?: string | number | null;
|
productMeasurementId?: string | number | null;
|
||||||
|
measurementType?: string | null;
|
||||||
|
measurementValue?: string | null;
|
||||||
price?: string | number | null;
|
price?: string | number | null;
|
||||||
warehouses?: ProductWarehouseRef[];
|
warehouses?: ProductWarehouseRef[];
|
||||||
}
|
}
|
||||||
@ -48,6 +50,8 @@ interface ProductMeasurementRef {
|
|||||||
interface ProductModelRef {
|
interface ProductModelRef {
|
||||||
id?: string | number | null;
|
id?: string | number | null;
|
||||||
productModelId?: string | number | null;
|
productModelId?: string | number | null;
|
||||||
|
name?: string | null;
|
||||||
|
sku?: string | null;
|
||||||
isMeasurement?: boolean | null;
|
isMeasurement?: boolean | null;
|
||||||
price?: string | number | null;
|
price?: string | number | null;
|
||||||
warehouses?: ProductWarehouseRef[];
|
warehouses?: ProductWarehouseRef[];
|
||||||
@ -58,15 +62,33 @@ interface ProductDetailRef {
|
|||||||
productModels?: ProductModelRef[];
|
productModels?: ProductModelRef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StockPriceTarget {
|
interface WarehouseLookup {
|
||||||
product: ProductRow;
|
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;
|
currentPrice: number;
|
||||||
currentStock: number;
|
currentStock: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StockPriceTarget {
|
||||||
|
product: ProductRow;
|
||||||
nextPrice: string;
|
nextPrice: string;
|
||||||
nextStock: string;
|
nextStock: string;
|
||||||
productModelId: string;
|
productModelId: string;
|
||||||
productMeasurementId: string;
|
productMeasurementId: string;
|
||||||
warehouseId: string;
|
warehouseId: string;
|
||||||
|
options: StockPriceOption[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
@ -114,67 +136,176 @@ function formatCurrencyValue(value: number) {
|
|||||||
return `Rp ${new Intl.NumberFormat("id-ID").format(value)}`;
|
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 models = Array.isArray(product.productModels) ? product.productModels : [];
|
||||||
const model = models[0];
|
const options: StockPriceOption[] = [];
|
||||||
|
|
||||||
if (!model) {
|
models.forEach((model, modelIndex) => {
|
||||||
return null;
|
const modelId = toId(model.id ?? model.productModelId);
|
||||||
}
|
if (!modelId) return;
|
||||||
|
|
||||||
const productModelId = toId(model.id ?? model.productModelId);
|
const modelLabel = buildModelLabel(model, modelIndex);
|
||||||
const modelWarehouses = Array.isArray(model.warehouses) ? model.warehouses : [];
|
const modelWarehouses = Array.isArray(model.warehouses) ? model.warehouses : [];
|
||||||
const modelWarehouse = modelWarehouses[0];
|
const modelWarehousesWithIds = modelWarehouses.filter(
|
||||||
const modelWarehouseId = toId(modelWarehouse?.id ?? modelWarehouse?.warehouseId);
|
(warehouse) => Boolean(toId(warehouse.id ?? warehouse.warehouseId))
|
||||||
|
);
|
||||||
|
const measurements = Array.isArray(model.productMeasurements)
|
||||||
|
? model.productMeasurements
|
||||||
|
: [];
|
||||||
|
|
||||||
if (!productModelId || !modelWarehouseId) {
|
if (model.isMeasurement === false || measurements.length === 0) {
|
||||||
return null;
|
modelWarehousesWithIds.forEach((warehouse, warehouseIndex) => {
|
||||||
}
|
const warehouseId = toId(warehouse.id ?? warehouse.warehouseId);
|
||||||
|
if (!warehouseId) return;
|
||||||
|
|
||||||
if (model.isMeasurement === false) {
|
options.push({
|
||||||
return {
|
key: [modelId, "", warehouseId].join("::"),
|
||||||
productModelId,
|
modelId,
|
||||||
productMeasurementId: "",
|
modelLabel,
|
||||||
warehouseId: modelWarehouseId,
|
measurementId: "",
|
||||||
currentPrice: toNumber(model.price),
|
measurementLabel: "",
|
||||||
currentStock: toNumber(modelWarehouse?.stock),
|
warehouseId,
|
||||||
};
|
warehouseLabel: buildWarehouseLabel(warehouse, warehouseIndex, warehouseLookupMap),
|
||||||
}
|
currentPrice: toNumber(model.price),
|
||||||
|
currentStock: toNumber(warehouse.stock),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const measurements = Array.isArray(model.productMeasurements)
|
return;
|
||||||
? model.productMeasurements
|
}
|
||||||
: [];
|
|
||||||
const measurement = measurements[0];
|
|
||||||
|
|
||||||
if (!measurement) {
|
measurements.forEach((measurement, measurementIndex) => {
|
||||||
return {
|
const measurementId = toId(measurement.id ?? measurement.productMeasurementId);
|
||||||
productModelId,
|
const measurementLabel = buildMeasurementLabel(measurement, measurementIndex);
|
||||||
productMeasurementId: "",
|
const rawMeasurementWarehouses = Array.isArray(measurement.warehouses)
|
||||||
warehouseId: modelWarehouseId,
|
? measurement.warehouses
|
||||||
currentPrice: toNumber(model.price),
|
: [];
|
||||||
currentStock: toNumber(modelWarehouse?.stock),
|
const measurementWarehousesWithIds = rawMeasurementWarehouses.filter(
|
||||||
};
|
(warehouse) => Boolean(toId(warehouse.id ?? warehouse.warehouseId))
|
||||||
}
|
);
|
||||||
|
const measurementWarehouses =
|
||||||
|
measurementWarehousesWithIds.length > 0
|
||||||
|
? measurementWarehousesWithIds
|
||||||
|
: modelWarehousesWithIds;
|
||||||
|
|
||||||
const measurementWarehouses = Array.isArray(measurement.warehouses)
|
measurementWarehouses.forEach((warehouse, warehouseIndex) => {
|
||||||
? measurement.warehouses
|
const warehouseId = toId(warehouse.id ?? warehouse.warehouseId);
|
||||||
: [];
|
if (!warehouseId) return;
|
||||||
const warehouse = measurementWarehouses[0] || modelWarehouse;
|
|
||||||
const warehouseId = toId(warehouse?.id ?? warehouse?.warehouseId);
|
|
||||||
|
|
||||||
if (!warehouseId) {
|
options.push({
|
||||||
return null;
|
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 {
|
return options;
|
||||||
productModelId,
|
|
||||||
productMeasurementId: toId(
|
|
||||||
measurement.id ?? measurement.productMeasurementId
|
|
||||||
),
|
|
||||||
warehouseId,
|
|
||||||
currentPrice: toNumber(measurement.price ?? model.price),
|
|
||||||
currentStock: toNumber(warehouse?.stock),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function tabFromQuery(tab: string | null): TabLabel {
|
function tabFromQuery(tab: string | null): TabLabel {
|
||||||
@ -284,6 +415,9 @@ function StockPriceModal({
|
|||||||
state,
|
state,
|
||||||
d,
|
d,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onModelChange,
|
||||||
|
onMeasurementChange,
|
||||||
|
onWarehouseChange,
|
||||||
onPriceChange,
|
onPriceChange,
|
||||||
onStockChange,
|
onStockChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
@ -297,15 +431,60 @@ function StockPriceModal({
|
|||||||
currentStock: string;
|
currentStock: string;
|
||||||
newPrice: string;
|
newPrice: string;
|
||||||
newStock: string;
|
newStock: string;
|
||||||
|
model: string;
|
||||||
|
measurement: string;
|
||||||
|
warehouse: string;
|
||||||
|
noMeasurement: string;
|
||||||
cancel: string;
|
cancel: string;
|
||||||
confirm: string;
|
confirm: string;
|
||||||
processing: string;
|
processing: string;
|
||||||
};
|
};
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
onModelChange: (value: string) => void;
|
||||||
|
onMeasurementChange: (value: string) => void;
|
||||||
|
onWarehouseChange: (value: string) => void;
|
||||||
onPriceChange: (value: string) => void;
|
onPriceChange: (value: string) => void;
|
||||||
onStockChange: (value: string) => void;
|
onStockChange: (value: string) => void;
|
||||||
onSubmit: () => 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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<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} />
|
<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>
|
<p className="mt-0.5 text-xs text-outline">ID: {state.product.id}</p>
|
||||||
</div>
|
</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="mt-6 grid gap-4 md:grid-cols-2">
|
||||||
<div className="rounded-xl border border-outline-variant/15 bg-surface-container-low p-4">
|
<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="text-[10px] font-black uppercase tracking-widest text-outline">{d.currentPrice}</p>
|
||||||
<p className="mt-2 text-lg font-black text-on-surface">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-outline-variant/15 bg-surface-container-low p-4">
|
<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="text-[10px] font-black uppercase tracking-widest text-outline">{d.currentStock}</p>
|
||||||
<p className="mt-2 text-lg font-black text-on-surface">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -434,6 +671,7 @@ function ProductsPageInner() {
|
|||||||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||||||
const [restoringId, setRestoringId] = useState<string | null>(null);
|
const [restoringId, setRestoringId] = useState<string | null>(null);
|
||||||
const [stockPriceTarget, setStockPriceTarget] = useState<StockPriceTarget | null>(null);
|
const [stockPriceTarget, setStockPriceTarget] = useState<StockPriceTarget | null>(null);
|
||||||
|
const [warehouseLookupMap, setWarehouseLookupMap] = useState<Record<string, WarehouseLookup>>({});
|
||||||
const [openActionMenuId, setOpenActionMenuId] = useState<string | null>(null);
|
const [openActionMenuId, setOpenActionMenuId] = useState<string | null>(null);
|
||||||
const actionMenuRef = useRef<HTMLDivElement | null>(null);
|
const 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(() => {
|
useEffect(() => {
|
||||||
async function loadProducts() {
|
async function loadProducts() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -609,13 +875,12 @@ function ProductsPageInner() {
|
|||||||
|
|
||||||
setStockPriceTarget({
|
setStockPriceTarget({
|
||||||
product,
|
product,
|
||||||
currentPrice: product.minPrice || 0,
|
|
||||||
currentStock: product.totalStock || 0,
|
|
||||||
nextPrice: String(product.minPrice || 0),
|
nextPrice: String(product.minPrice || 0),
|
||||||
nextStock: String(product.totalStock || 0),
|
nextStock: String(product.totalStock || 0),
|
||||||
productModelId: "",
|
productModelId: "",
|
||||||
productMeasurementId: "",
|
productMeasurementId: "",
|
||||||
warehouseId: "",
|
warehouseId: "",
|
||||||
|
options: [],
|
||||||
loading: true,
|
loading: true,
|
||||||
submitting: false,
|
submitting: false,
|
||||||
error: "",
|
error: "",
|
||||||
@ -632,9 +897,15 @@ function ProductsPageInner() {
|
|||||||
throw new Error(result?.responseDesc || p.stockPriceDialog.loadError);
|
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);
|
throw new Error(p.stockPriceDialog.targetError);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -642,7 +913,10 @@ function ProductsPageInner() {
|
|||||||
current && current.product.id === product.id
|
current && current.product.id === product.id
|
||||||
? {
|
? {
|
||||||
...current,
|
...current,
|
||||||
...resolved,
|
productModelId: resolved.modelId,
|
||||||
|
productMeasurementId: resolved.measurementId,
|
||||||
|
warehouseId: resolved.warehouseId,
|
||||||
|
options,
|
||||||
nextPrice: String(resolved.currentPrice),
|
nextPrice: String(resolved.currentPrice),
|
||||||
nextStock: String(resolved.currentStock),
|
nextStock: String(resolved.currentStock),
|
||||||
loading: false,
|
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() {
|
async function handleSubmitStockPrice() {
|
||||||
if (!stockPriceTarget) return;
|
if (!stockPriceTarget) return;
|
||||||
|
|
||||||
@ -871,6 +1208,7 @@ function ProductsPageInner() {
|
|||||||
) : (
|
) : (
|
||||||
rows.map((product) => {
|
rows.map((product) => {
|
||||||
const productState = getProductState(product);
|
const productState = getProductState(product);
|
||||||
|
const productStateMeta = getProductStateMeta(productState, p);
|
||||||
const isInactiveInAllTab =
|
const isInactiveInAllTab =
|
||||||
activeTab === "All Product" &&
|
activeTab === "All Product" &&
|
||||||
(productState === "UNPUBLISHED" ||
|
(productState === "UNPUBLISHED" ||
|
||||||
@ -925,9 +1263,16 @@ function ProductsPageInner() {
|
|||||||
>
|
>
|
||||||
{product.name}
|
{product.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] font-medium text-outline">
|
<div className="mt-1 flex items-center gap-2 flex-wrap">
|
||||||
ID: {product.id.slice(0, 8)}
|
<p className="text-[10px] font-medium text-outline">
|
||||||
</p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -1231,6 +1576,9 @@ function ProductsPageInner() {
|
|||||||
setStockPriceTarget(null);
|
setStockPriceTarget(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onModelChange={handleStockPriceModelChange}
|
||||||
|
onMeasurementChange={handleStockPriceMeasurementChange}
|
||||||
|
onWarehouseChange={handleStockPriceWarehouseChange}
|
||||||
onPriceChange={(value) =>
|
onPriceChange={(value) =>
|
||||||
setStockPriceTarget((current) =>
|
setStockPriceTarget((current) =>
|
||||||
current ? { ...current, nextPrice: value, error: "" } : 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}`;
|
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 ───────────────────────────────────────────────────
|
// ─── Shared sub-components ───────────────────────────────────────────────────
|
||||||
|
|
||||||
function Row({ label, value }: { label: string; value?: string | number | boolean | null }) {
|
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 (
|
return (
|
||||||
<div className={`rounded-xl border shadow-sm p-5 ${accent ? "border-primary/30 bg-primary/5" : "bg-white border-slate-100"}`}>
|
<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"}`}>
|
||||||
<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"}`}>
|
<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"}`}>
|
||||||
{title}
|
<h3 className={`text-[10px] font-black uppercase tracking-[0.18em] ${changed ? "text-amber-700" : accent ? "text-primary" : "text-slate-400"}`}>
|
||||||
</h3>
|
{title}
|
||||||
{children}
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -81,8 +335,17 @@ function isProductUpdateFromCompare(
|
|||||||
return change.isUpdate === true || hasDifferentIds;
|
return change.isUpdate === true || hasDifferentIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
function ProductColumn({
|
||||||
function ProductColumn({ product, label, accent }: { product: any; label: string; accent?: boolean }) {
|
product,
|
||||||
|
label,
|
||||||
|
accent,
|
||||||
|
compareRows = [],
|
||||||
|
}: {
|
||||||
|
product: ReviewProductData | null;
|
||||||
|
label: string;
|
||||||
|
accent?: boolean;
|
||||||
|
compareRows?: CompareRow[];
|
||||||
|
}) {
|
||||||
if (!product) return (
|
if (!product) return (
|
||||||
<div className="flex-1 flex items-center justify-center py-20 text-slate-400 text-sm">Memuat data...</div>
|
<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 images = Array.isArray(product.productImages) ? product.productImages : [];
|
||||||
const features = Array.isArray(product.productFeatures) ? product.productFeatures : [];
|
const features = Array.isArray(product.productFeatures) ? product.productFeatures : [];
|
||||||
const keywords = Array.isArray(product.productKeyWords) ? product.productKeyWords : [];
|
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 (
|
return (
|
||||||
<div className="flex-1 min-w-0 space-y-4">
|
<div className="flex-1 min-w-0 space-y-4">
|
||||||
@ -100,11 +373,11 @@ function ProductColumn({ product, label, accent }: { product: any; label: string
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Images */}
|
{/* Images */}
|
||||||
{(images.length > 0 || imgUrl(product.imageId)) && (
|
{allImages.length > 0 && (
|
||||||
<SectionCard title="Gambar Produk" accent={accent}>
|
<SectionCard title="Gambar Produk" accent={accent} changed={hasChangesForPaths(compareRows, ["imageId", "image", "productImages"])}>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{(images.length > 0 ? images : [{ imageId: product.imageId }]).map((img: { imageId?: string }, i: number) => {
|
{allImages.map((imageId: string, i: number) => {
|
||||||
const url = imgUrl(img.imageId);
|
const url = imgUrl(imageId);
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
@ -116,7 +389,20 @@ function ProductColumn({ product, label, accent }: { product: any; label: string
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Basic Info */}
|
{/* 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="Nama" value={product.name} />
|
||||||
<Row label="Deskripsi" value={product.description} />
|
<Row label="Deskripsi" value={product.description} />
|
||||||
<Row label="Kategori" value={product.subCategory?.category?.name} />
|
<Row label="Kategori" value={product.subCategory?.category?.name} />
|
||||||
@ -130,7 +416,7 @@ function ProductColumn({ product, label, accent }: { product: any; label: string
|
|||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
{features.length > 0 && (
|
{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">
|
<ul className="space-y-1.5">
|
||||||
{features.map((f: string, i: number) => (
|
{features.map((f: string, i: number) => (
|
||||||
<li key={i} className="flex items-start gap-2 text-sm">
|
<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 */}
|
||||||
{keywords.length > 0 && (
|
{keywords.length > 0 && (
|
||||||
<SectionCard title="Keywords" accent={accent}>
|
<SectionCard title="Keywords" accent={accent} changed={hasChangesForPaths(compareRows, ["productKeyWords"])}>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{keywords.map((k: string) => (
|
{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>
|
<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 */}
|
||||||
{models.length > 0 && (
|
{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">
|
<div className="space-y-3">
|
||||||
{models.map((m: {
|
{models.map((model: ReviewModel, index: number) => (
|
||||||
name?: string; sku?: string; price?: number; currency?: string;
|
<ModelCard
|
||||||
weight?: number; weightType?: string; length?: number; width?: number; height?: number;
|
key={`${model.sku || model.name || index}`}
|
||||||
dimensionType?: string; isConfigurePromotionPrice?: boolean; promotionPrice?: number;
|
model={model}
|
||||||
imageId?: string;
|
index={index}
|
||||||
warehouses?: { id: string; city?: string; province?: string; country?: string; stock?: number }[];
|
accent={accent}
|
||||||
}, i: number) => (
|
changed={hasChangesForPaths(compareRows, ["productModels"])}
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@ -196,7 +458,7 @@ function ProductColumn({ product, label, accent }: { product: any; label: string
|
|||||||
|
|
||||||
{/* Compliance */}
|
{/* Compliance */}
|
||||||
{product.complianceInformation && (
|
{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="Negara Asal" value={product.complianceInformation.countryOfOrigin} />
|
||||||
<Row label="Safety Warning" value={product.complianceInformation.safetyWarning} />
|
<Row label="Safety Warning" value={product.complianceInformation.safetyWarning} />
|
||||||
<Row label="Dangerous Goods" value={product.complianceInformation.isDangerousGoodRegulation} />
|
<Row label="Dangerous Goods" value={product.complianceInformation.isDangerousGoodRegulation} />
|
||||||
@ -205,7 +467,7 @@ function ProductColumn({ product, label, accent }: { product: any; label: string
|
|||||||
|
|
||||||
{/* Warranty */}
|
{/* Warranty */}
|
||||||
{product.warrantyInformation && (
|
{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="Tipe" value={product.warrantyInformation.type} />
|
||||||
<Row label="Durasi" value={product.warrantyInformation.duration ? `${product.warrantyInformation.duration} ${product.warrantyInformation.durationType}` : undefined} />
|
<Row label="Durasi" value={product.warrantyInformation.duration ? `${product.warrantyInformation.duration} ${product.warrantyInformation.durationType}` : undefined} />
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@ -223,12 +485,11 @@ function AdminReviewDetailPageInner() {
|
|||||||
const isReadonly = searchParams.get("readonly") === "1";
|
const isReadonly = searchParams.get("readonly") === "1";
|
||||||
const backHref = isReadonly ? "/admin/products" : "/admin/review";
|
const backHref = isReadonly ? "/admin/products" : "/admin/review";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const [product, setProduct] = useState<ReviewProductData | null>(null); // updated (review)
|
||||||
const [product, setProduct] = useState<any>(null); // updated (review)
|
const [oldProduct, setOldProduct] = useState<ReviewProductData | null>(null); // original (compare)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const [oldProduct, setOldProduct] = useState<any>(null); // original (compare)
|
|
||||||
const [isComparison, setIsComparison] = useState(false);
|
const [isComparison, setIsComparison] = useState(false);
|
||||||
const [isUpdateProduct, setIsUpdateProduct] = useState(false);
|
const [isUpdateProduct, setIsUpdateProduct] = useState(false);
|
||||||
|
const [compareRows, setCompareRows] = useState<CompareRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadError, setLoadError] = useState("");
|
const [loadError, setLoadError] = useState("");
|
||||||
|
|
||||||
@ -246,6 +507,7 @@ function AdminReviewDetailPageInner() {
|
|||||||
setOldProduct(null);
|
setOldProduct(null);
|
||||||
setIsComparison(false);
|
setIsComparison(false);
|
||||||
setIsUpdateProduct(false);
|
setIsUpdateProduct(false);
|
||||||
|
setCompareRows([]);
|
||||||
|
|
||||||
const reviewFetch = fetch(`/api/admin/review/${params.productId}`, {
|
const reviewFetch = fetch(`/api/admin/review/${params.productId}`, {
|
||||||
headers: { "x-auth-token": getToken() },
|
headers: { "x-auth-token": getToken() },
|
||||||
@ -276,6 +538,7 @@ function AdminReviewDetailPageInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setProduct(updated);
|
setProduct(updated);
|
||||||
|
setCompareRows(Array.isArray(compareData?.data) ? compareData.data : []);
|
||||||
|
|
||||||
const isUpdate = isProductUpdateFromCompare(idChange);
|
const isUpdate = isProductUpdateFromCompare(idChange);
|
||||||
setIsUpdateProduct(isUpdate);
|
setIsUpdateProduct(isUpdate);
|
||||||
@ -436,7 +699,7 @@ function AdminReviewDetailPageInner() {
|
|||||||
{/* Content — 1 column (isNew) or 2 columns (update) */}
|
{/* Content — 1 column (isNew) or 2 columns (update) */}
|
||||||
{isComparison ? (
|
{isComparison ? (
|
||||||
<div className="flex gap-6 items-start">
|
<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)" />
|
<ProductColumn product={oldProduct} label="Versi Saat Ini (Live)" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
const adminProductSubmenu = [
|
const adminProductSubmenu = [
|
||||||
@ -11,6 +10,7 @@ const adminProductSubmenu = [
|
|||||||
|
|
||||||
function AdminProductSubmenuNavInner() {
|
function AdminProductSubmenuNavInner() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const rawTab = searchParams.get("tab") ?? "";
|
const rawTab = searchParams.get("tab") ?? "";
|
||||||
const currentTab = rawTab === "all" ? "" : rawTab;
|
const currentTab = rawTab === "all" ? "" : rawTab;
|
||||||
@ -20,7 +20,7 @@ function AdminProductSubmenuNavInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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) => {
|
{adminProductSubmenu.map((submenu) => {
|
||||||
const submenuTab = new URLSearchParams(
|
const submenuTab = new URLSearchParams(
|
||||||
submenu.href.split("?")[1] || ""
|
submenu.href.split("?")[1] || ""
|
||||||
@ -31,17 +31,18 @@ function AdminProductSubmenuNavInner() {
|
|||||||
(isAllProduct ? currentTab === "" : submenuTab === currentTab);
|
(isAllProduct ? currentTab === "" : submenuTab === currentTab);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<button
|
||||||
key={submenu.href}
|
key={submenu.href}
|
||||||
href={submenu.href}
|
type="button"
|
||||||
className={`block rounded-lg px-4 py-2 text-sm font-semibold transition-colors ${
|
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
|
isActive
|
||||||
? "bg-white text-primary shadow-sm"
|
? "bg-white text-primary shadow-sm"
|
||||||
: "text-slate-500 hover:bg-slate-100 hover:text-primary"
|
: "text-slate-500 hover:bg-slate-100 hover:text-primary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{submenu.label}
|
{submenu.label}
|
||||||
</Link>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
const productSubmenu = [
|
const productSubmenu = [
|
||||||
@ -17,6 +16,7 @@ const productSubmenu = [
|
|||||||
|
|
||||||
function ProductSubmenuNavInner() {
|
function ProductSubmenuNavInner() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const rawTab = searchParams.get("tab") ?? "";
|
const rawTab = searchParams.get("tab") ?? "";
|
||||||
const currentTab = rawTab === "all" ? "" : rawTab;
|
const currentTab = rawTab === "all" ? "" : rawTab;
|
||||||
@ -24,7 +24,7 @@ function ProductSubmenuNavInner() {
|
|||||||
if (pathname !== "/products" && !pathname.startsWith("/products/")) return null;
|
if (pathname !== "/products" && !pathname.startsWith("/products/")) return null;
|
||||||
|
|
||||||
return (
|
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) => {
|
{productSubmenu.map((submenu) => {
|
||||||
const submenuTab = new URLSearchParams(
|
const submenuTab = new URLSearchParams(
|
||||||
submenu.href.split("?")[1] || ""
|
submenu.href.split("?")[1] || ""
|
||||||
@ -35,17 +35,18 @@ function ProductSubmenuNavInner() {
|
|||||||
(isAllProduct ? currentTab === "" : submenuTab === currentTab);
|
(isAllProduct ? currentTab === "" : submenuTab === currentTab);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<button
|
||||||
key={submenu.href}
|
key={submenu.href}
|
||||||
href={submenu.href}
|
type="button"
|
||||||
className={`flex items-center py-2 pl-3 text-sm font-semibold transition-all rounded-r-xl ${
|
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
|
isSubmenuActive
|
||||||
? "bg-white text-primary border-l-4 border-primary shadow-sm rounded-l-none"
|
? "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"
|
: "text-on-surface-variant hover:text-primary hover:bg-surface-container/60 border-l-2 border-surface-container"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{submenu.label}
|
{submenu.label}
|
||||||
</Link>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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",
|
restore: "Restore",
|
||||||
unpublish: "Unpublish",
|
unpublish: "Unpublish",
|
||||||
deletedByAdmin: "Deleted by admin",
|
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: {
|
table: {
|
||||||
product: "Product",
|
product: "Product",
|
||||||
price: "Price",
|
price: "Price",
|
||||||
@ -400,6 +410,10 @@ export const en = {
|
|||||||
currentStock: "Current Stock",
|
currentStock: "Current Stock",
|
||||||
newPrice: "New Price",
|
newPrice: "New Price",
|
||||||
newStock: "New Stock",
|
newStock: "New Stock",
|
||||||
|
model: "Model",
|
||||||
|
measurement: "Measurement",
|
||||||
|
warehouse: "Warehouse",
|
||||||
|
noMeasurement: "No measurement",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
confirm: "Submit",
|
confirm: "Submit",
|
||||||
processing: "Processing...",
|
processing: "Processing...",
|
||||||
|
|||||||
@ -365,6 +365,16 @@ export const id = {
|
|||||||
restore: "Restore",
|
restore: "Restore",
|
||||||
unpublish: "Unpublish",
|
unpublish: "Unpublish",
|
||||||
deletedByAdmin: "Dihapus oleh admin",
|
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: {
|
table: {
|
||||||
product: "Produk",
|
product: "Produk",
|
||||||
price: "Harga",
|
price: "Harga",
|
||||||
@ -401,6 +411,10 @@ export const id = {
|
|||||||
currentStock: "Stok Sekarang",
|
currentStock: "Stok Sekarang",
|
||||||
newPrice: "Harga Baru",
|
newPrice: "Harga Baru",
|
||||||
newStock: "Stok Baru",
|
newStock: "Stok Baru",
|
||||||
|
model: "Model",
|
||||||
|
measurement: "Measurement",
|
||||||
|
warehouse: "Gudang",
|
||||||
|
noMeasurement: "Tanpa measurement",
|
||||||
cancel: "Batal",
|
cancel: "Batal",
|
||||||
confirm: "Submit",
|
confirm: "Submit",
|
||||||
processing: "Memproses...",
|
processing: "Memproses...",
|
||||||
|
|||||||
@ -34,55 +34,59 @@ export function buildProductPayload(draft: ProductDraftState, state: "DRAFT" | "
|
|||||||
name: model.name,
|
name: model.name,
|
||||||
sku: model.sku,
|
sku: model.sku,
|
||||||
imageId: model.imageId || undefined,
|
imageId: model.imageId || undefined,
|
||||||
price: toNumber(model.price),
|
price: model.hasMeasurements ? 0 : toNumber(model.price),
|
||||||
currency: model.currency,
|
currency: model.currency,
|
||||||
weight: toNumber(model.weight),
|
weight: model.hasMeasurements ? 0 : toNumber(model.weight),
|
||||||
weightType: model.weightType || "G",
|
weightType: model.weightType || "G",
|
||||||
length: toNumber(model.length),
|
length: model.hasMeasurements ? 0 : toNumber(model.length),
|
||||||
width: toNumber(model.width),
|
width: model.hasMeasurements ? 0 : toNumber(model.width),
|
||||||
height: toNumber(model.height),
|
height: model.hasMeasurements ? 0 : toNumber(model.height),
|
||||||
dimensionType: model.dimensionType || "CM",
|
dimensionType: model.dimensionType || "CM",
|
||||||
isMeasurement: model.measurements.length > 0,
|
isMeasurement: model.hasMeasurements,
|
||||||
isConfigurePromotionPrice: model.hasPromotion,
|
isConfigurePromotionPrice: model.hasMeasurements ? false : model.hasPromotion,
|
||||||
promotionPrice: model.hasPromotion ? toNumber(model.promotionPrice) : 0,
|
promotionPrice: model.hasMeasurements ? 0 : model.hasPromotion ? toNumber(model.promotionPrice) : 0,
|
||||||
promotionCurrency: model.promotionCurrency || model.currency,
|
promotionCurrency: model.hasMeasurements ? model.currency : model.promotionCurrency || model.currency,
|
||||||
promotionStartDate: model.promotionStartDate || undefined,
|
promotionStartDate: model.hasMeasurements ? undefined : model.promotionStartDate || undefined,
|
||||||
promotionEndDate: model.promotionEndDate || undefined,
|
promotionEndDate: model.hasMeasurements ? undefined : model.promotionEndDate || undefined,
|
||||||
packagingWeight: toNumber(model.packagingWeight),
|
packagingWeight: model.hasMeasurements ? 0 : toNumber(model.packagingWeight),
|
||||||
packagingWeightType: model.packagingWeightType || "G",
|
packagingWeightType: model.packagingWeightType || "G",
|
||||||
packagingLength: toNumber(model.packagingLength),
|
packagingLength: model.hasMeasurements ? 0 : toNumber(model.packagingLength),
|
||||||
packagingWidth: toNumber(model.packagingWidth),
|
packagingWidth: model.hasMeasurements ? 0 : toNumber(model.packagingWidth),
|
||||||
packagingHeight: toNumber(model.packagingHeight),
|
packagingHeight: model.hasMeasurements ? 0 : toNumber(model.packagingHeight),
|
||||||
packagingDimensionType: model.packagingDimensionType || "CM",
|
packagingDimensionType: model.packagingDimensionType || "CM",
|
||||||
warehouses: model.warehouses
|
warehouses: model.hasMeasurements
|
||||||
.filter((w) => w.id)
|
? []
|
||||||
.map((w) => ({ id: w.id, stock: Number(w.stock || 0) })),
|
: model.warehouses
|
||||||
productMeasurements: model.measurements.map((m) => ({
|
.filter((w) => w.id)
|
||||||
measurementType: m.measurementType,
|
.map((w) => ({ id: w.id, stock: Number(w.stock || 0) })),
|
||||||
measurementValue: m.measurementValue,
|
productMeasurements: model.hasMeasurements
|
||||||
price: toNumber(m.price),
|
? model.measurements.map((m) => ({
|
||||||
currency: m.currency,
|
measurementType: m.measurementType,
|
||||||
weight: toNumber(m.weight),
|
measurementValue: m.measurementValue,
|
||||||
weightType: m.weightType || "G",
|
price: toNumber(m.price),
|
||||||
length: toNumber(m.length),
|
currency: m.currency,
|
||||||
width: toNumber(m.width),
|
weight: toNumber(m.weight),
|
||||||
height: toNumber(m.height),
|
weightType: m.weightType || "G",
|
||||||
dimensionType: m.dimensionType || "CM",
|
length: toNumber(m.length),
|
||||||
isConfigurePromotionPrice: m.hasPromotion,
|
width: toNumber(m.width),
|
||||||
promotionPrice: m.hasPromotion ? toNumber(m.promotionPrice) : 0,
|
height: toNumber(m.height),
|
||||||
promotionCurrency: m.promotionCurrency || m.currency,
|
dimensionType: m.dimensionType || "CM",
|
||||||
promotionStartDate: m.promotionStartDate || undefined,
|
isConfigurePromotionPrice: m.hasPromotion,
|
||||||
promotionEndDate: m.promotionEndDate || undefined,
|
promotionPrice: m.hasPromotion ? toNumber(m.promotionPrice) : 0,
|
||||||
packagingWeight: toNumber(m.packagingWeight),
|
promotionCurrency: m.promotionCurrency || m.currency,
|
||||||
packagingWeightType: m.packagingWeightType || "G",
|
promotionStartDate: m.promotionStartDate || undefined,
|
||||||
packagingLength: toNumber(m.packagingLength),
|
promotionEndDate: m.promotionEndDate || undefined,
|
||||||
packagingWidth: toNumber(m.packagingWidth),
|
packagingWeight: toNumber(m.packagingWeight),
|
||||||
packagingHeight: toNumber(m.packagingHeight),
|
packagingWeightType: m.packagingWeightType || "G",
|
||||||
packagingDimensionType: m.packagingDimensionType || "CM",
|
packagingLength: toNumber(m.packagingLength),
|
||||||
warehouses: m.warehouses
|
packagingWidth: toNumber(m.packagingWidth),
|
||||||
.filter((w) => w.id)
|
packagingHeight: toNumber(m.packagingHeight),
|
||||||
.map((w) => ({ id: w.id, stock: Number(w.stock || 0) })),
|
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(
|
productInformations: draft.productInformations.filter(
|
||||||
(i) => i.paramName && i.paramValue
|
(i) => i.paramName && i.paramValue
|
||||||
|
|||||||
Reference in New Issue
Block a user