Update onboarding and product edit flows

This commit is contained in:
2026-05-08 09:47:03 +07:00
parent a1e20ccd9b
commit 0aa5227943
9 changed files with 334 additions and 83 deletions

View File

@ -90,6 +90,7 @@ interface EditState {
isNew: boolean;
isEligibleToExport: boolean;
imageId: string;
productFiles: Array<{ id: string; name: string }>;
productImages: string[];
keywords: string[];
features: string[];
@ -153,6 +154,14 @@ interface ApiProductImage {
imageId?: string | number | null;
}
interface ApiProductFile {
id?: string | number | null;
fileId?: string | number | null;
name?: string | number | null;
fileName?: string | number | null;
file?: string | number | null;
}
interface ApiParamItem {
paramName?: string;
paramValue?: string;
@ -178,6 +187,7 @@ interface ApiProduct {
isNew?: boolean | null;
isEligibleToExport?: boolean | null;
imageId?: string | number | null;
productFiles?: ApiProductFile[] | Array<string | number>;
productImages?: ApiProductImage[];
productKeyWords?: string[];
productFeatures?: string[];
@ -215,6 +225,50 @@ function toStr(v: string | number | null | undefined): string {
return String(v);
}
function toProductFiles(items: ApiProduct["productFiles"]): Array<{ id: string; name: string }> {
if (!Array.isArray(items)) return [];
return items
.map((item) => {
if (typeof item === "string" || typeof item === "number") {
const id = toStr(item);
return id ? { id, name: id } : null;
}
const id = toStr(item?.fileId ?? item?.id ?? item?.file);
if (!id) return null;
return {
id,
name: toStr(item?.name ?? item?.fileName ?? item?.file) || id,
};
})
.filter((item): item is { id: string; name: string } => Boolean(item));
}
async function uploadFile(file: File) {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload", {
method: "POST",
headers: { "x-auth-token": getToken() },
body: fd,
});
const data = await res.json();
if (!res.ok) {
throw new Error(data?.responseDesc || data?.error || "Upload gagal");
}
const fileId = data?.data?.fileId || data?.data?.id || data?.fileId || data?.id || "";
if (!fileId) {
throw new Error("File id tidak ditemukan");
}
return { fileId: String(fileId), fileName: file.name };
}
function newMeasurement(): EditMeasurement {
return {
_key: `meas-${Date.now()}-${Math.random().toString(36).slice(2)}`,
@ -354,6 +408,7 @@ function apiToEditState(data: ApiProduct): EditState {
isNew: data?.isNew !== false,
isEligibleToExport: !!data?.isEligibleToExport,
imageId: toStr(data?.imageId),
productFiles: toProductFiles(data?.productFiles),
productImages: Array.isArray(data?.productImages)
? data.productImages
.sort(
@ -420,18 +475,8 @@ function ImageSlotUpload({
setUploading(true);
setError("");
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload", {
method: "POST",
headers: { "x-auth-token": getToken() },
body: fd,
});
const data = await res.json();
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal");
const id = data?.data?.id || data?.data?.fileId || data?.fileId || "";
if (!id) throw new Error("File id tidak ditemukan");
onUploaded(id);
const { fileId } = await uploadFile(file);
onUploaded(fileId);
} catch (err) {
setError(err instanceof Error ? err.message : "Upload gagal");
} finally {
@ -483,18 +528,8 @@ function ModelImageUpload({ value, onUploaded }: { value: string; onUploaded: (i
setUploading(true);
setError("");
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload", {
method: "POST",
headers: { "x-auth-token": getToken() },
body: fd,
});
const data = await res.json();
if (!res.ok) throw new Error(data?.responseDesc || data?.error || "Upload gagal");
const id = data?.data?.id || data?.data?.fileId || data?.fileId || "";
if (!id) throw new Error("File id tidak ditemukan");
onUploaded(id);
const { fileId } = await uploadFile(file);
onUploaded(fileId);
} catch (err) {
setError(err instanceof Error ? err.message : "Upload gagal");
setPreviewUrl("");
@ -1009,9 +1044,15 @@ function EditProductPageInner() {
const [loadingSubcategories, setLoadingSubcategories] = useState(false);
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const [keywordInput, setKeywordInput] = useState("");
const [debugPayload, setDebugPayload] = useState<string>("");
const [publishing, setPublishing] = useState(false);
const [publishSuccess, setPublishSuccess] = useState(false);
const [msdsName, setMsdsName] = useState("");
const [uploadingMsds, setUploadingMsds] = useState(false);
const [uploadingDoc, setUploadingDoc] = useState(false);
const [msdsError, setMsdsError] = useState("");
const [docError, setDocError] = useState("");
const msdsInputRef = useRef<HTMLInputElement | null>(null);
const docInputRef = useRef<HTMLInputElement | null>(null);
// Load product, categories (with subcategory resolution), and warehouses
useEffect(() => {
@ -1078,7 +1119,7 @@ function EditProductPageInner() {
}
loadAll();
}, [params.productId]);
}, [isDraftParam, params.productId]);
// Load subcategories when category changes (for draft editing)
useEffect(() => {
@ -1113,17 +1154,62 @@ function EditProductPageInner() {
setKeywordInput("");
}
function removeDoc(id: string) {
if (!form) return;
update({ productFiles: form.productFiles.filter((file) => file.id !== id) });
}
async function handleMsdsUpload(file: File) {
setUploadingMsds(true);
setMsdsError("");
try {
const { fileId, fileName } = await uploadFile(file);
update({
complianceInformation: { ...form!.complianceInformation, fileId },
});
setMsdsName(fileName);
} catch (err) {
setMsdsError(err instanceof Error ? err.message : "Upload gagal");
} finally {
setUploadingMsds(false);
}
}
async function handleSupportingDocumentsUpload(files: File[]) {
if (!form || files.length === 0) return;
setUploadingDoc(true);
setDocError("");
try {
const uploaded: Array<{ id: string; name: string }> = [];
for (const file of files) {
const { fileId, fileName } = await uploadFile(file);
uploaded.push({ id: fileId, name: fileName });
}
update({
productFiles: [...form.productFiles, ...uploaded],
});
} catch (err) {
setDocError(err instanceof Error ? err.message : "Upload gagal");
} finally {
setUploadingDoc(false);
}
}
function buildPayload(state?: "DRAFT" | "PUBLISHED") {
if (!form) return null;
const resolvedState = state ?? "DRAFT";
const base = {
subCategory: form.subCategoryId ? { id: form.subCategoryId } : undefined,
name: form.name,
description: form.description,
isPreOrder: form.isPreOrder,
preOrderDay: form.isPreOrder ? toNum(form.preOrderDay) : 0,
preOrderDay: form.isPreOrder ? toNum(form.preOrderDay) : null,
isNew: form.isNew,
isEligibleToExport: form.isEligibleToExport,
imageId: form.imageId || undefined,
productFiles: form.productFiles.map((file) => file.id).filter(Boolean),
productImages: form.productImages.filter(Boolean).map((imageId, i) => ({ imageId, sequence: i + 1 })),
productKeyWords: form.keywords.filter(Boolean),
productFeatures: form.features.filter(Boolean),
@ -1141,10 +1227,10 @@ function EditProductPageInner() {
dimensionType: m.dimensionType || "CM",
isMeasurement: m.measurements.length > 0,
isConfigurePromotionPrice: m.hasPromotion,
promotionPrice: m.hasPromotion ? toNum(m.promotionPrice) : 0,
promotionCurrency: m.promotionCurrency || m.currency,
promotionStartDate: m.promotionStartDate || undefined,
promotionEndDate: m.promotionEndDate || undefined,
promotionPrice: m.hasPromotion ? toNum(m.promotionPrice) : null,
promotionCurrency: m.hasPromotion ? (m.promotionCurrency || m.currency) : null,
promotionStartDate: m.hasPromotion ? (m.promotionStartDate || null) : null,
promotionEndDate: m.hasPromotion ? (m.promotionEndDate || null) : null,
packagingWeight: toNum(m.packagingWeight),
packagingWeightType: m.packagingWeightType || "G",
packagingLength: toNum(m.packagingLength),
@ -1170,10 +1256,10 @@ function EditProductPageInner() {
packagingHeight: toNum(ms.packagingHeight),
packagingDimensionType: ms.packagingDimensionType || "CM",
isConfigurePromotionPrice: ms.hasPromotion,
promotionPrice: ms.hasPromotion ? toNum(ms.promotionPrice) : 0,
promotionCurrency: ms.promotionCurrency || ms.currency || "IDR",
promotionStartDate: ms.promotionStartDate || undefined,
promotionEndDate: ms.promotionEndDate || undefined,
promotionPrice: ms.hasPromotion ? toNum(ms.promotionPrice) : null,
promotionCurrency: ms.hasPromotion ? (ms.promotionCurrency || ms.currency || "IDR") : null,
promotionStartDate: ms.hasPromotion ? (ms.promotionStartDate || null) : null,
promotionEndDate: ms.hasPromotion ? (ms.promotionEndDate || null) : null,
warehouses: ms.warehouses.filter((w) => w.id).map((w) => ({ id: w.id, stock: Number(w.stock || 0) })),
})),
})),
@ -1181,8 +1267,9 @@ function EditProductPageInner() {
categoryInformations: form.categoryInformations.filter((i) => i.paramName && i.paramValue),
complianceInformation: { ...form.complianceInformation },
warrantyInformation: { ...form.warrantyInformation, duration: toNum(form.warrantyInformation.duration) },
state: resolvedState,
};
return state ? { ...base, state } : base;
return base;
}
async function handleSaveDraft() {
@ -1195,7 +1282,6 @@ function EditProductPageInner() {
try {
const payload = buildPayload();
setDebugPayload(JSON.stringify(payload, null, 2));
const res = await fetch(`/api/products/${params.productId}?draft=1`, {
method: "PUT",
@ -1227,7 +1313,6 @@ function EditProductPageInner() {
try {
const payload = buildPayload("PUBLISHED");
setDebugPayload(JSON.stringify(payload, null, 2));
const res = await fetch("/api/products/create", {
method: "POST",
@ -1259,9 +1344,6 @@ function EditProductPageInner() {
try {
const payload = buildPayload();
const payloadJson = JSON.stringify(payload, null, 2);
console.log("[EditProduct] PUT payload:", payloadJson);
setDebugPayload(payloadJson);
const res = await fetch(`/api/products/${params.productId}`, {
method: "PUT",
@ -1638,6 +1720,60 @@ function EditProductPageInner() {
onChange={(ev) => update({ complianceInformation: { ...form.complianceInformation, isDangerousGoodRegulation: ev.target.checked } })}
className="h-5 w-5 rounded border-outline-variant" />
</label>
<div>
<p className="block text-[11px] font-black uppercase tracking-[0.18em] text-outline mb-2">MSDS / Compliance File</p>
<input
ref={msdsInputRef}
type="file"
accept=".pdf,.doc,.docx"
className="hidden"
onChange={(ev) => {
const file = ev.target.files?.[0];
if (file) handleMsdsUpload(file);
ev.target.value = "";
}}
/>
{form.complianceInformation.fileId ? (
<div className="flex items-center gap-3 bg-surface-container-low rounded-xl px-4 py-3">
<span className="material-symbols-outlined text-primary text-[20px]">description</span>
<span className="flex-1 text-sm font-semibold text-on-surface truncate">
{msdsName || form.complianceInformation.fileId}
</span>
<button
type="button"
onClick={() => {
update({
complianceInformation: { ...form.complianceInformation, fileId: "" },
});
setMsdsName("");
}}
className="text-outline hover:text-error transition-colors"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
) : (
<button
type="button"
onClick={() => msdsInputRef.current?.click()}
disabled={uploadingMsds}
className="w-full border border-dashed border-outline-variant rounded-xl px-4 py-6 flex flex-col items-center justify-center gap-2 bg-surface-container-low/20 hover:bg-surface-container-low/60 transition-all disabled:opacity-60"
>
{uploadingMsds ? (
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
) : (
<span className="material-symbols-outlined text-primary text-2xl">upload_file</span>
)}
<div className="text-center">
<p className="text-sm font-bold text-on-surface">
{uploadingMsds ? e.uploading : "Upload MSDS / Compliance File"}
</p>
<p className="text-[10px] text-on-surface-variant">PDF, DOC, DOCX</p>
</div>
</button>
)}
{msdsError && <p className="text-[11px] text-error mt-1.5 font-semibold">{msdsError}</p>}
</div>
</div>
<div className="border-t border-surface-container" />
@ -1661,6 +1797,66 @@ function EditProductPageInner() {
</select>
</div>
</div>
<div className="border-t border-surface-container" />
{/* Supporting Documents */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-black text-on-surface">Product Supporting Documents</h3>
<p className="text-[11px] text-on-surface-variant mt-1">
Existing file IDs are preserved and new uploads will append to the update payload.
</p>
</div>
<div className="space-y-2">
{form.productFiles.map((doc) => (
<div key={doc.id} className="flex items-center gap-3 bg-surface-container-low rounded-xl px-4 py-3">
<span className="material-symbols-outlined text-primary text-[20px]">description</span>
<span className="flex-1 text-sm font-semibold text-on-surface truncate">
{doc.name || doc.id}
</span>
<button
type="button"
onClick={() => removeDoc(doc.id)}
className="text-outline hover:text-error transition-colors"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
))}
</div>
<input
ref={docInputRef}
type="file"
accept=".pdf,.doc,.docx,.xlsx,.xls,.ppt,.pptx,.zip"
multiple
className="hidden"
onChange={async (ev) => {
const files = Array.from(ev.target.files || []);
await handleSupportingDocumentsUpload(files);
ev.target.value = "";
}}
/>
<button
type="button"
onClick={() => docInputRef.current?.click()}
disabled={uploadingDoc}
className="w-full border border-dashed border-outline-variant rounded-xl px-4 py-7 flex flex-col items-center justify-center gap-2 bg-surface-container-low/20 hover:bg-surface-container-low/60 transition-all disabled:opacity-60"
>
{uploadingDoc ? (
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
) : (
<span className="material-symbols-outlined text-primary text-2xl">cloud_upload</span>
)}
<div className="text-center">
<p className="text-sm font-bold text-on-surface">
{uploadingDoc ? e.uploading : "Upload Supporting Documents"}
</p>
<p className="text-[10px] text-on-surface-variant">PDF, DOCX, XLSX, PPTX, ZIP</p>
</div>
</button>
{docError && <p className="text-[11px] text-error mt-1.5 font-semibold">{docError}</p>}
</div>
</div>
{/* ── Fixed Bottom Footer ───────────────────────────────────────────── */}