From 01df3f41d4520c2c529408c5bb08a4ed89504691 Mon Sep 17 00:00:00 2001 From: Anang Yusman Date: Thu, 29 Jan 2026 02:29:25 +0800 Subject: [PATCH] fix:CRUD product,promo --- .../admin/product/update/[id]/page.tsx | 10 + components/dialog/promo-dialog.tsx | 2 +- components/dialog/promo-edit-dialog.tsx | 181 +++++++++ components/form/agent/detail-agent-form.tsx | 2 +- .../form/product/create-product-form.tsx | 210 ++++++++-- .../form/product/detail-product-form.tsx | 171 ++++---- .../form/product/update-product-form.tsx | 364 +++++++++++++++--- components/table/product-table.tsx | 2 +- components/table/promotion-table.tsx | 29 +- next.config.ts | 18 +- package-lock.json | 3 +- 11 files changed, 810 insertions(+), 182 deletions(-) create mode 100644 app/(admin)/admin/product/update/[id]/page.tsx create mode 100644 components/dialog/promo-edit-dialog.tsx diff --git a/app/(admin)/admin/product/update/[id]/page.tsx b/app/(admin)/admin/product/update/[id]/page.tsx new file mode 100644 index 0000000..f40b42a --- /dev/null +++ b/app/(admin)/admin/product/update/[id]/page.tsx @@ -0,0 +1,10 @@ +import UpdateProductForm from "@/components/form/product/update-product-form"; + +export default function UpdateProductPage() { + return ( +
+ +
+ ); +} + diff --git a/components/dialog/promo-dialog.tsx b/components/dialog/promo-dialog.tsx index 9aa9527..a334d54 100644 --- a/components/dialog/promo-dialog.tsx +++ b/components/dialog/promo-dialog.tsx @@ -289,7 +289,7 @@ export default function PromoDetailDialog({
- Profile void; + onSuccess?: () => void; +}; + +export default function PromoEditDialog({ + promoId, + open, + onOpenChange, + onSuccess, +}: PromoEditDialogProps) { + const [loadingData, setLoadingData] = useState(false); + + // 🔹 FORM STATE + const [title, setTitle] = useState(""); + const [thumbnailUrl, setThumbnailUrl] = useState(null); + const [thumbnailFile, setThumbnailFile] = useState(null); + + const MySwal = withReactContent(Swal); + + /* ========================= + * Fetch promo detail + ========================= */ + useEffect(() => { + if (!promoId || !open) return; + + async function fetchPromo() { + try { + setLoadingData(true); + const res = await getPromotionById(promoId); + const data = res?.data?.data; + + setTitle(data?.title || ""); + setThumbnailUrl(data?.thumbnail_url || null); + } catch (e) { + console.error("FETCH PROMO ERROR:", e); + } finally { + setLoadingData(false); + } + } + + fetchPromo(); + }, [promoId, open]); + + if (!open) return null; + + /* ========================= + * Handlers + ========================= */ + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setThumbnailFile(file); + setThumbnailUrl(URL.createObjectURL(file)); + }; + + const handleSubmit = async () => { + if (!promoId) return; + + const formData = new FormData(); + formData.append("title", title); + + if (thumbnailFile) { + formData.append("file", thumbnailFile); + } + + loading(); + const res = await updatePromotion(formData, promoId); + + if (res?.error) { + error(res.message || "Gagal update promo"); + return; + } + + success("Promo berhasil diperbarui"); + onOpenChange(false); + onSuccess?.(); + }; + + /* ========================= + * Render + ========================= */ + return ( +
onOpenChange(false)} + > +
e.stopPropagation()} + > + {/* HEADER */} +
+ +

Edit Promo

+
+ + {/* BODY */} +
+ {loadingData ? ( +

Memuat data...

+ ) : ( + <> + {/* TITLE */} +
+ + setTitle(e.target.value)} + className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-[#0F6C75]" + placeholder="Judul promo" + /> +
+ + {/* THUMBNAIL */} +
+ + + {thumbnailUrl && ( +
+ Thumbnail +
+ )} + + +
+ + )} +
+ + {/* FOOTER */} +
+ + + +
+
+
+ ); +} diff --git a/components/form/agent/detail-agent-form.tsx b/components/form/agent/detail-agent-form.tsx index 5e99e32..b0b150b 100644 --- a/components/form/agent/detail-agent-form.tsx +++ b/components/form/agent/detail-agent-form.tsx @@ -278,7 +278,7 @@ export default function DetailAgentForm(props: { isDetail: boolean }) {
- Profile(null); const [specs, setSpecs] = useState< - { id: number; title: string; file: File | null }[] - >([{ id: 1, title: "", file: null }]); + { id: number; title: string; images: string[]; files: File[] }[] + >([{ id: 1, title: "", images: [], files: [] }]); + + const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false); + const [uploadTarget, setUploadTarget] = useState<{ + type: "spec"; + index: number; + } | null>(null); + + const fileInputId = "spec-upload-input"; + const fileInputRef = useRef(null); + const isUploadingRef = useRef(false); const [file, setFile] = useState(null); const router = useRouter(); @@ -43,9 +62,12 @@ export default function AddProductForm() { if (selected) setFile(selected); }; - // const handleAddSpec = () => { - // setSpecs((prev) => [...prev, { id: prev.length + 1 }]); - // }; + const handleAddSpec = () => { + setSpecs((prev) => [ + ...prev, + { id: prev.length + 1, title: "", images: [], files: [] }, + ]); + }; const { register, handleSubmit, @@ -61,18 +83,57 @@ export default function AddProductForm() { setSpecs(updated); }; - const handleSpecFileChange = ( - index: number, - e: React.ChangeEvent, - ) => { - const file = e.target.files?.[0] || null; - const updated = [...specs]; - updated[index].file = file; - setSpecs(updated); + const handleFileSelected = (e: React.ChangeEvent) => { + const input = e.target; + const selectedFile = input.files?.[0]; + + if (!selectedFile || !uploadTarget) return; + + // 🔒 CEGAH DOUBLE EVENT + if (isUploadingRef.current) return; + isUploadingRef.current = true; + + setSpecs((prev) => { + const updated = [...prev]; + const spec = updated[uploadTarget.index]; + + // max 5 gambar + if (spec.files.length >= 5) { + isUploadingRef.current = false; + return prev; + } + + // cegah file sama + if ( + spec.files.some( + (f) => f.name === selectedFile.name && f.size === selectedFile.size, + ) + ) { + isUploadingRef.current = false; + return prev; + } + + const previewUrl = URL.createObjectURL(selectedFile); + + spec.images = [...spec.images, previewUrl]; + spec.files = [...spec.files, selectedFile]; + + return updated; + }); + + // reset input + input.value = ""; + setIsUploadDialogOpen(false); + + // release lock + setTimeout(() => { + isUploadingRef.current = false; + }, 0); }; const onSubmit = async (data: z.infer) => { try { + loading(); const formData = new FormData(); formData.append("title", data.name); @@ -99,17 +160,18 @@ export default function AddProductForm() { } }); - // 🔥 specifications JSON + // 🔥 specifications JSON (include image count for backend processing) const specificationsPayload = specs.map((s) => ({ title: s.title, + imageCount: s.files.length, })); formData.append("specifications", JSON.stringify(specificationsPayload)); - // 🔥 imagespecification_url (files) + // 🔥 specification images (multiple files per spec) specs.forEach((s) => { - if (s.file) { - formData.append("imagespecification_url", s.file); - } + s.files.forEach((file) => { + formData.append("specification_images", file); + }); }); await createProduct(formData); @@ -338,7 +400,10 @@ export default function AddProductForm() { {specs.map((spec, index) => ( -
+
@@ -353,39 +418,58 @@ export default function AddProductForm() { Foto Spesifikasi {index + 1} -
))} - {/* */} +
+ + + + + ); } diff --git a/components/form/product/detail-product-form.tsx b/components/form/product/detail-product-form.tsx index 997b681..b6f609c 100644 --- a/components/form/product/detail-product-form.tsx +++ b/components/form/product/detail-product-form.tsx @@ -159,13 +159,13 @@ export default function DetailProductForm(props: { isDetail: boolean }) { setError, clearErrors, } = useForm(formOptions); - const [specs, setSpecs] = useState([ + const [specs, setSpecs] = useState< { - id: 1, - title: "Jaecoo 7 SHS Teknologi dan Exterior", - images: ["/spec1.jpg", "/spec2.jpg", "/spec3.jpg", "/spec4.jpg"], - }, - ]); + id: number; + title: string; + images: string[]; + }[] + >([]); type ColorType = { id: number; @@ -174,20 +174,7 @@ export default function DetailProductForm(props: { isDetail: boolean }) { colorSelected: string | null; }; - const [colors, setColors] = useState([ - { - id: 1, - name: "", - preview: "/car-1.png", - colorSelected: null, - }, - { - id: 2, - name: "", - preview: "/car-2.png", - colorSelected: null, - }, - ]); + const [colors, setColors] = useState([]); const palette = [ "#1E4E52", @@ -297,13 +284,31 @@ export default function DetailProductForm(props: { isDetail: boolean }) { setThumbnail(data.thumbnail_url); // colors - if (data.colors?.length) { + if (Array.isArray(data.colors)) { setColors( - data.colors.map((color: string, index: number) => ({ + data.colors.map((color: any, index: number) => ({ + id: color.id ?? index + 1, + name: color.name ?? "", + preview: + typeof color.image_url === "string" && color.image_url.length > 0 + ? color.image_url + : "/car-default.png", // fallback aman + colorSelected: color.name ?? null, + })), + ); + } + + // specifications + if (Array.isArray(data.specifications)) { + setSpecs( + data.specifications.map((spec: any, index: number) => ({ id: index + 1, - name: color, - preview: data.thumbnail_url, - colorSelected: color, + title: spec.title ?? "", + images: Array.isArray(spec.image_urls) + ? spec.image_urls.filter( + (url: any) => typeof url === "string" && url.length > 0, + ) + : [], })), ); } @@ -509,36 +514,44 @@ export default function DetailProductForm(props: { isDetail: boolean }) {
-
- {colors.map((item, index) => ( -
- + {colors.length === 0 ? ( +

+ Tidak ada data warna +

+ ) : ( +
+ {colors.map((item, index) => ( +
+ - {/* Preview warna */} -
- - {/* Foto mobil */} -
- warna -
-

- Foto Produk Warna {index + 1} -

-
- ))} -
+ {/* Foto mobil */} +
+ {`warna-${index}`} +
+ +

+ Foto Produk Warna {index + 1} +

+
+ ))} +
+ )}
@@ -546,29 +559,45 @@ export default function DetailProductForm(props: { isDetail: boolean }) { Spesifikasi Produk - {specs.map((spec) => ( -
- + {specs.length === 0 ? ( +

+ Tidak ada spesifikasi +

+ ) : ( + specs.map((spec) => ( +
+ -
- {spec.images.map((img, i) => ( -
- spec + {spec.images.length === 0 ? ( +

+ Tidak ada gambar spesifikasi +

+ ) : ( +
+ {spec.images.map((img, i) => ( +
+ {`spec-${i}`} +
+ ))}
- ))} + )}
-
- ))} + )) + )}
+

Status Timeline diff --git a/components/form/product/update-product-form.tsx b/components/form/product/update-product-form.tsx index d9e10dd..5c8b900 100644 --- a/components/form/product/update-product-form.tsx +++ b/components/form/product/update-product-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Upload, Plus, Settings } from "lucide-react"; import Image from "next/image"; import { Input } from "@/components/ui/input"; @@ -14,15 +14,19 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { useParams } from "next/navigation"; +import { getProductDataById, updateProduct } from "@/service/product"; +import { useRouter } from "next/navigation"; +import { success, error, close, loading } from "@/config/swal"; export default function UpdateProductForm() { - const [specs, setSpecs] = useState([ - { - id: 1, - title: "Jaecoo 7 SHS Teknologi dan Exterior", - images: ["/spec1.jpg", "/spec2.jpg", "/spec3.jpg", "/spec4.jpg"], - }, - ]); + const params = useParams(); + const id = params?.id; + const router = useRouter(); + const [specs, setSpecs] = useState< + { id: number; title: string; images: string[]; files: File[] }[] + >([]); + const [specFiles, setSpecFiles] = useState>(new Map()); type ColorType = { id: number; @@ -31,20 +35,12 @@ export default function UpdateProductForm() { colorSelected: string | null; }; - const [colors, setColors] = useState([ - { - id: 1, - name: "", - preview: "/car-1.png", - colorSelected: null, - }, - { - id: 2, - name: "", - preview: "/car-2.png", - colorSelected: null, - }, - ]); + const [colors, setColors] = useState([]); + const [colorFiles, setColorFiles] = useState>(new Map()); + const [thumbnail, setThumbnail] = useState(""); + const [title, setTitle] = useState(""); + const [variant, setVariant] = useState(""); + const [price, setPrice] = useState(""); const palette = [ "#1E4E52", @@ -69,6 +65,7 @@ export default function UpdateProductForm() { id: prev.length + 1, title: "", images: [], + files: [], }, ]); }; @@ -86,42 +83,197 @@ export default function UpdateProductForm() { }; const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false); + const [bannerFile, setBannerFile] = useState(null); + const [uploadTarget, setUploadTarget] = useState<{ - type: "spec" | "color"; - index: number; + type: "spec" | "color" | "banner"; + index?: number; } | null>(null); const fileInputId = "file-upload-input"; - const handleFileSelected = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; + const handleFileSelected = (e: React.ChangeEvent) => { + const input = e.target; + const file = input.files?.[0]; if (!file || !uploadTarget) return; - const reader = new FileReader(); - reader.onload = () => { - const fileUrl = reader.result as string; + const previewUrl = URL.createObjectURL(file); - if (uploadTarget.type === "spec") { - setSpecs((prev) => { - const updated = [...prev]; - updated[uploadTarget.index].images.push(fileUrl); - return updated; - }); - } + // ===================== SPEC ===================== + if (uploadTarget.type === "spec") { + if (uploadTarget.index === undefined) return; - if (uploadTarget.type === "color") { - setColors((prev) => { - const updated = [...prev]; - updated[uploadTarget.index].preview = fileUrl; - return updated; - }); - } - }; + const index = uploadTarget.index; - reader.readAsDataURL(file); + // preview + setSpecs((prev) => { + const updated = [...prev]; + updated[index].images = [...updated[index].images, previewUrl]; + return updated; + }); + + // file upload + setSpecFiles((prev) => { + const newMap = new Map(prev); + const files = newMap.get(index) || []; + newMap.set(index, [...files, file]); + return newMap; + }); + } + + // ===================== COLOR ===================== + if (uploadTarget.type === "color") { + if (uploadTarget.index === undefined) return; + + const index = uploadTarget.index; + + setColors((prev) => { + const updated = [...prev]; + updated[index].preview = previewUrl; + return updated; + }); + + setColorFiles((prev) => { + const newMap = new Map(prev); + newMap.set(index, file); + return newMap; + }); + } + + // ===================== BANNER ===================== + if (uploadTarget.type === "banner") { + setThumbnail(previewUrl); + setBannerFile(file); + } + + input.value = ""; setIsUploadDialogOpen(false); }; + const formatRupiah = (value: string) => + "Rp " + Number(value).toLocaleString("id-ID"); + + useEffect(() => { + if (id) { + initState(); + } + }, [id]); + + async function initState() { + if (!id) return; + + try { + loading(); + const res = await getProductDataById(id); + const data = res?.data?.data; + + if (!data) { + error("Produk tidak ditemukan"); + return; + } + close(); + + // Set form values + setTitle(data.title || ""); + setVariant(data.variant || ""); + setPrice(formatRupiah(data.price || "0")); + setThumbnail(data.thumbnail_url || ""); + + // Set colors + if (data.colors?.length) { + setColors( + data.colors.map((color: any, index: number) => ({ + id: index + 1, + name: color.name || "", + preview: color.image_url || data.thumbnail_url || "", + colorSelected: color.name || null, + })), + ); + } else { + setColors([]); + } + + // Set specifications + if (data.specifications?.length) { + setSpecs( + data.specifications.map((spec: any, index: number) => ({ + id: index + 1, + title: spec.title || "", + images: spec.image_urls || [], + files: [], + })), + ); + } else { + setSpecs([]); + } + } catch (err) { + error("Gagal memuat data produk"); + console.error(err); + } + } + + const handleSubmit = async () => { + if (!id) { + error("ID produk tidak ditemukan"); + return; + } + + try { + loading(); + const formData = new FormData(); + + formData.append("title", title); + if (variant) formData.append("variant", variant); + if (price) { + const priceValue = price.replace(/\D/g, ""); + formData.append("price", priceValue); + } + + // Colors JSON + const colorsPayload = colors.map((c) => ({ + name: c.name, + })); + formData.append("colors", JSON.stringify(colorsPayload)); + + // Color images (only new files if uploaded) + // Append files in order of color indices + colors.forEach((_, index) => { + const file = colorFiles.get(index); + if (file) { + formData.append("color_images", file); + } + }); + + if (bannerFile) { + formData.append("file", bannerFile); + } + + // Specifications JSON (include image count for new files only) + const specificationsPayload = specs.map((s, index) => { + const newFiles = specFiles.get(index) || []; + return { + title: s.title, + imageCount: newFiles.length, // Only count new files being uploaded + }; + }); + formData.append("specifications", JSON.stringify(specificationsPayload)); + + specs.forEach((_, index) => { + const files = specFiles.get(index) || []; + files.forEach((file) => { + formData.append("specification_images", file); + }); + }); + + await updateProduct(formData, id); + success("Produk berhasil diperbarui"); + router.push("/admin/product"); + } catch (err) { + error("Gagal memperbarui produk"); + console.error(err); + } + }; + return ( <> @@ -135,17 +287,64 @@ export default function UpdateProductForm() {
- + setTitle(e.target.value)} + placeholder="Masukkan nama produk" + />
- + setVariant(e.target.value)} + placeholder="Masukkan varian" + />
- + { + const rawValue = e.target.value.replace(/\D/g, ""); + setPrice(formatRupiah(rawValue)); + }} + placeholder="Masukkan harga" + /> +
+
+ +
+ +
+
+ {thumbnail ? ( + Thumbnail + ) : ( +
+ No Image +
+ )} +
+ +
@@ -161,7 +360,15 @@ export default function UpdateProductForm() { { + setColors((prev) => { + const updated = [...prev]; + updated[index].name = e.target.value; + updated[index].colorSelected = e.target.value; + return updated; + }); + }} />
@@ -183,6 +390,7 @@ export default function UpdateProductForm() { setColors((prev) => { const updated = [...prev]; updated[index].colorSelected = colorCode; + updated[index].name = colorCode; // ✅ sinkron ke input return updated; }); }} @@ -238,9 +446,16 @@ export default function UpdateProductForm() { Judul Spesifikasi {index + 1} { + setSpecs((prev) => { + const updated = [...prev]; + updated[index].title = e.target.value; + return updated; + }); + }} />