Update onboarding and product edit flows
This commit is contained in:
@ -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 ───────────────────────────────────────────── */}
|
||||
|
||||
Reference in New Issue
Block a user