fix:CRUD product,promo

This commit is contained in:
Anang Yusman 2026-01-29 02:29:25 +08:00
parent 83eae371a6
commit 01df3f41d4
11 changed files with 810 additions and 182 deletions

View File

@ -0,0 +1,10 @@
import UpdateProductForm from "@/components/form/product/update-product-form";
export default function UpdateProductPage() {
return (
<div className="bg-slate-100 lg:p-3 dark:!bg-black overflow-y-auto">
<UpdateProductForm />
</div>
);
}

View File

@ -289,7 +289,7 @@ export default function PromoDetailDialog({
<div className="space-y-4 text-sm"> <div className="space-y-4 text-sm">
<div> <div>
<div className="w-24 h-24 rounded-lg overflow-hidden border"> <div className="w-24 h-24 rounded-lg overflow-hidden border">
<Image <img
src={promo.thumbnail_url} src={promo.thumbnail_url}
alt="Profile" alt="Profile"
width={96} width={96}

View File

@ -0,0 +1,181 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import Cookies from "js-cookie";
import Swal from "sweetalert2";
import withReactContent from "sweetalert2-react-content";
import { getPromotionById, updatePromotion } from "@/service/promotion";
import { Button } from "@/components/ui/button";
import { loading, success, error } from "@/config/swal";
type PromoEditDialogProps = {
promoId: number | null;
open: boolean;
onOpenChange: (open: boolean) => 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<string | null>(null);
const [thumbnailFile, setThumbnailFile] = useState<File | null>(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<HTMLInputElement>) => {
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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={() => onOpenChange(false)}
>
<div
className="bg-white rounded-2xl shadow-xl max-w-lg w-full overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* HEADER */}
<div className="bg-gradient-to-br from-[#1F6779] to-[#0F6C75] text-white px-6 py-5 relative">
<button
onClick={() => onOpenChange(false)}
className="absolute top-4 right-4 text-xl"
>
</button>
<h2 className="text-lg font-semibold">Edit Promo</h2>
</div>
{/* BODY */}
<div className="p-6 space-y-5">
{loadingData ? (
<p>Memuat data...</p>
) : (
<>
{/* TITLE */}
<div>
<label className="block text-sm font-medium text-gray-600 mb-1">
Judul Promo
</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-[#0F6C75]"
placeholder="Judul promo"
/>
</div>
{/* THUMBNAIL */}
<div>
<label className="block text-sm font-medium text-gray-600 mb-2">
Thumbnail
</label>
{thumbnailUrl && (
<div className="w-32 h-32 mb-3 rounded-lg overflow-hidden border">
<img
src={thumbnailUrl}
alt="Thumbnail"
className="w-32 h-32 object-cover rounded-lg border"
/>
</div>
)}
<input
type="file"
accept="image/*"
onChange={handleFileChange}
className="bg-[#0F6C75] text-white rounded-full px-2 w-[230px]"
/>
</div>
</>
)}
</div>
{/* FOOTER */}
<div className="flex justify-end gap-3 px-6 py-4 border-t bg-[#F2F7FA]">
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Batal
</Button>
<Button
className="bg-[#1F6779] hover:bg-[#0F6C75] text-white"
onClick={handleSubmit}
disabled={loadingData}
>
Simpan Perubahan
</Button>
</div>
</div>
</div>
);
}

View File

@ -278,7 +278,7 @@ export default function DetailAgentForm(props: { isDetail: boolean }) {
<div> <div>
<Label className="mb-2 block">Foto Profile</Label> <Label className="mb-2 block">Foto Profile</Label>
<div className="w-24 h-24 rounded-lg overflow-hidden border"> <div className="w-24 h-24 rounded-lg overflow-hidden border">
<Image <img
src={data?.profile_picture_url} src={data?.profile_picture_url}
alt="Profile" alt="Profile"
width={96} width={96}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod"; import * as z from "zod";
@ -8,6 +8,14 @@ import * as z from "zod";
import { Upload, Plus, UploadCloud } from "lucide-react"; import { Upload, Plus, UploadCloud } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Label } from "@radix-ui/react-dropdown-menu"; import { Label } from "@radix-ui/react-dropdown-menu";
import Image from "next/image";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -15,6 +23,7 @@ import { createProduct } from "@/service/product";
import withReactContent from "sweetalert2-react-content"; import withReactContent from "sweetalert2-react-content";
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { loading } from "@/config/swal";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(1, "Nama produk wajib diisi"), name: z.string().min(1, "Nama produk wajib diisi"),
@ -30,8 +39,18 @@ export default function AddProductForm() {
const [selectedColor, setSelectedColor] = useState<string | null>(null); const [selectedColor, setSelectedColor] = useState<string | null>(null);
const [specs, setSpecs] = useState< const [specs, setSpecs] = useState<
{ id: number; title: string; file: File | null }[] { id: number; title: string; images: string[]; files: File[] }[]
>([{ id: 1, title: "", file: null }]); >([{ 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<HTMLInputElement | null>(null);
const isUploadingRef = useRef(false);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const router = useRouter(); const router = useRouter();
@ -43,9 +62,12 @@ export default function AddProductForm() {
if (selected) setFile(selected); if (selected) setFile(selected);
}; };
// const handleAddSpec = () => { const handleAddSpec = () => {
// setSpecs((prev) => [...prev, { id: prev.length + 1 }]); setSpecs((prev) => [
// }; ...prev,
{ id: prev.length + 1, title: "", images: [], files: [] },
]);
};
const { const {
register, register,
handleSubmit, handleSubmit,
@ -61,18 +83,57 @@ export default function AddProductForm() {
setSpecs(updated); setSpecs(updated);
}; };
const handleSpecFileChange = ( const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
index: number, const input = e.target;
e: React.ChangeEvent<HTMLInputElement>, const selectedFile = input.files?.[0];
) => {
const file = e.target.files?.[0] || null; if (!selectedFile || !uploadTarget) return;
const updated = [...specs];
updated[index].file = file; // 🔒 CEGAH DOUBLE EVENT
setSpecs(updated); 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<typeof formSchema>) => { const onSubmit = async (data: z.infer<typeof formSchema>) => {
try { try {
loading();
const formData = new FormData(); const formData = new FormData();
formData.append("title", data.name); 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) => ({ const specificationsPayload = specs.map((s) => ({
title: s.title, title: s.title,
imageCount: s.files.length,
})); }));
formData.append("specifications", JSON.stringify(specificationsPayload)); formData.append("specifications", JSON.stringify(specificationsPayload));
// 🔥 imagespecification_url (files) // 🔥 specification images (multiple files per spec)
specs.forEach((s) => { specs.forEach((s) => {
if (s.file) { s.files.forEach((file) => {
formData.append("imagespecification_url", s.file); formData.append("specification_images", file);
} });
}); });
await createProduct(formData); await createProduct(formData);
@ -338,7 +400,10 @@ export default function AddProductForm() {
</Label> </Label>
{specs.map((spec, index) => ( {specs.map((spec, index) => (
<div key={spec.id} className="mt-4"> <div
key={spec.id}
className="mt-6 border-2 rounded-lg border-black p-3"
>
<Label className="text-sm font-semibold"> <Label className="text-sm font-semibold">
Judul Spesifikasi {index + 1} Judul Spesifikasi {index + 1}
</Label> </Label>
@ -353,39 +418,58 @@ export default function AddProductForm() {
Foto Spesifikasi {index + 1} Foto Spesifikasi {index + 1}
</Label> </Label>
<label <div className="flex flex-wrap gap-4 mt-2">
htmlFor={`spec-file-${index}`} {spec.images.map((img, i) => (
className="border-2 border-dashed rounded-lg flex flex-col items-center justify-center py-10 cursor-pointer hover:bg-gray-50 transition mt-1" <div key={i} className="relative">
> <Image
<Upload className="w-8 h-8 text-gray-400 mb-2" /> src={img}
<p className="text-sm text-gray-500 text-center"> width={120}
Klik untuk upload gambar spesifikasi height={120}
</p> alt="spec"
<p className="text-xs text-gray-400">PNG, JPG (max 5 MB)</p> className="rounded-lg border object-cover"
<input
id={`spec-file-${index}`}
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={(e) => handleSpecFileChange(index, e)}
/> />
</label> <button
type="button"
{spec.file && ( onClick={() => {
<p className="text-xs text-teal-700 mt-2">{spec.file.name}</p> setSpecs((prev) => {
)} const updated = [...prev];
updated[index].images.splice(i, 1);
updated[index].files.splice(i, 1);
return updated;
});
}}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-red-600"
>
×
</button>
</div> </div>
))} ))}
{/* <button <Button
type="button"
className="bg-teal-800 hover:bg-teal-900 text-white"
onClick={() => {
setUploadTarget({ type: "spec", index });
setIsUploadDialogOpen(true);
}}
>
<Plus className="w-4 h-4 mr-2" />
Tambah Foto
</Button>
</div>
<div className="my-4 border-b"></div>
</div>
))}
<Button
type="button" type="button"
onClick={handleAddSpec} onClick={handleAddSpec}
className="w-full bg-teal-800 hover:bg-teal-900 text-white py-3 rounded-lg mt-6 flex items-center justify-center gap-2" className="w-full bg-teal-800 hover:bg-teal-900 text-white py-3 rounded-lg mt-6 flex items-center justify-center gap-2"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Tambahkan Spesifikasi Baru Tambahkan Spesifikasi Baru
</button> */} </Button>
</div> </div>
<Button <Button
@ -396,6 +480,48 @@ export default function AddProductForm() {
</Button> </Button>
</form> </form>
</CardContent> </CardContent>
<Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-teal-900 font-semibold">
Upload File
</DialogTitle>
</DialogHeader>
<div className="border-2 border-dashed rounded-xl p-8 text-center">
<Upload className="w-10 h-10 text-gray-400 mx-auto mb-3" />
<p className="text-sm text-gray-500">
Klik tombol di bawah untuk memilih file
</p>
<p className="text-xs text-gray-400">PNG, JPG (max 2 MB)</p>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileSelected}
/>
<DialogFooter className="flex gap-3 pt-4">
<Button
variant="secondary"
onClick={() => setIsUploadDialogOpen(false)}
>
Batal
</Button>
<Button
onClick={() => fileInputRef.current?.click()}
className="bg-teal-800 hover:bg-teal-900 text-white"
>
Pilih File
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card> </Card>
); );
} }

View File

@ -159,13 +159,13 @@ export default function DetailProductForm(props: { isDetail: boolean }) {
setError, setError,
clearErrors, clearErrors,
} = useForm<UserSettingSchema>(formOptions); } = useForm<UserSettingSchema>(formOptions);
const [specs, setSpecs] = useState([ const [specs, setSpecs] = useState<
{ {
id: 1, id: number;
title: "Jaecoo 7 SHS Teknologi dan Exterior", title: string;
images: ["/spec1.jpg", "/spec2.jpg", "/spec3.jpg", "/spec4.jpg"], images: string[];
}, }[]
]); >([]);
type ColorType = { type ColorType = {
id: number; id: number;
@ -174,20 +174,7 @@ export default function DetailProductForm(props: { isDetail: boolean }) {
colorSelected: string | null; colorSelected: string | null;
}; };
const [colors, setColors] = useState<ColorType[]>([ const [colors, setColors] = useState<ColorType[]>([]);
{
id: 1,
name: "",
preview: "/car-1.png",
colorSelected: null,
},
{
id: 2,
name: "",
preview: "/car-2.png",
colorSelected: null,
},
]);
const palette = [ const palette = [
"#1E4E52", "#1E4E52",
@ -297,13 +284,31 @@ export default function DetailProductForm(props: { isDetail: boolean }) {
setThumbnail(data.thumbnail_url); setThumbnail(data.thumbnail_url);
// colors // colors
if (data.colors?.length) { if (Array.isArray(data.colors)) {
setColors( 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, id: index + 1,
name: color, title: spec.title ?? "",
preview: data.thumbnail_url, images: Array.isArray(spec.image_urls)
colorSelected: color, ? spec.image_urls.filter(
(url: any) => typeof url === "string" && url.length > 0,
)
: [],
})), })),
); );
} }
@ -509,6 +514,11 @@ export default function DetailProductForm(props: { isDetail: boolean }) {
<div> <div>
<Label className="font-semibold text-gray-700">Warna Produk</Label> <Label className="font-semibold text-gray-700">Warna Produk</Label>
{colors.length === 0 ? (
<p className="mt-3 text-sm text-gray-400 italic">
Tidak ada data warna
</p>
) : (
<div className="mt-4 grid grid-cols-1 md:grid-cols-4 gap-6 border-2 rounded-lg border-black p-3"> <div className="mt-4 grid grid-cols-1 md:grid-cols-4 gap-6 border-2 rounded-lg border-black p-3">
{colors.map((item, index) => ( {colors.map((item, index) => (
<div key={item.id} className="space-y-3"> <div key={item.id} className="space-y-3">
@ -519,14 +529,16 @@ export default function DetailProductForm(props: { isDetail: boolean }) {
{/* Preview warna */} {/* Preview warna */}
<div <div
className="w-12 h-12 rounded-full border" className="w-12 h-12 rounded-full border"
style={{ backgroundColor: item.colorSelected ?? "#e5e7eb" }} style={{
backgroundColor: item.colorSelected || "#e5e7eb",
}}
/> />
{/* Foto mobil */} {/* Foto mobil */}
<div className="w-full h-[90px] border rounded-lg overflow-hidden"> <div className="w-full h-[90px] border rounded-lg overflow-hidden">
<Image <Image
src={item.preview} src={item.preview}
alt="warna" alt={`warna-${index}`}
width={200} width={200}
height={120} height={120}
className="object-cover w-full h-full" className="object-cover w-full h-full"
@ -539,6 +551,7 @@ export default function DetailProductForm(props: { isDetail: boolean }) {
</div> </div>
))} ))}
</div> </div>
)}
</div> </div>
<div> <div>
@ -546,19 +559,32 @@ export default function DetailProductForm(props: { isDetail: boolean }) {
Spesifikasi Produk Spesifikasi Produk
</Label> </Label>
{specs.map((spec) => ( {specs.length === 0 ? (
<p className="mt-3 text-sm text-gray-400 italic">
Tidak ada spesifikasi
</p>
) : (
specs.map((spec) => (
<div <div
key={spec.id} key={spec.id}
className="mt-4 space-y-3 border-2 rounded-lg border-black p-3" className="mt-4 space-y-3 border-2 rounded-lg border-black p-3"
> >
<Input value={spec.title} readOnly className="font-medium" /> <Input value={spec.title} readOnly className="font-medium" />
{spec.images.length === 0 ? (
<p className="text-sm text-gray-400 italic">
Tidak ada gambar spesifikasi
</p>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{spec.images.map((img, i) => ( {spec.images.map((img, i) => (
<div key={i} className="border rounded-lg overflow-hidden"> <div
key={i}
className="border rounded-lg overflow-hidden"
>
<Image <Image
src={img} src={img}
alt="spec" alt={`spec-${i}`}
width={200} width={200}
height={200} height={200}
className="object-cover w-full h-[120px]" className="object-cover w-full h-[120px]"
@ -566,9 +592,12 @@ export default function DetailProductForm(props: { isDetail: boolean }) {
</div> </div>
))} ))}
</div> </div>
)}
</div> </div>
))} ))
)}
</div> </div>
<div> <div>
<h4 className="text-sm font-semibold text-gray-700 mb-3"> <h4 className="text-sm font-semibold text-gray-700 mb-3">
Status Timeline Status Timeline

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import { Upload, Plus, Settings } from "lucide-react"; import { Upload, Plus, Settings } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -14,15 +14,19 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } 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() { export default function UpdateProductForm() {
const [specs, setSpecs] = useState([ const params = useParams();
{ const id = params?.id;
id: 1, const router = useRouter();
title: "Jaecoo 7 SHS Teknologi dan Exterior", const [specs, setSpecs] = useState<
images: ["/spec1.jpg", "/spec2.jpg", "/spec3.jpg", "/spec4.jpg"], { id: number; title: string; images: string[]; files: File[] }[]
}, >([]);
]); const [specFiles, setSpecFiles] = useState<Map<number, File[]>>(new Map());
type ColorType = { type ColorType = {
id: number; id: number;
@ -31,20 +35,12 @@ export default function UpdateProductForm() {
colorSelected: string | null; colorSelected: string | null;
}; };
const [colors, setColors] = useState<ColorType[]>([ const [colors, setColors] = useState<ColorType[]>([]);
{ const [colorFiles, setColorFiles] = useState<Map<number, File>>(new Map());
id: 1, const [thumbnail, setThumbnail] = useState<string>("");
name: "", const [title, setTitle] = useState<string>("");
preview: "/car-1.png", const [variant, setVariant] = useState<string>("");
colorSelected: null, const [price, setPrice] = useState<string>("");
},
{
id: 2,
name: "",
preview: "/car-2.png",
colorSelected: null,
},
]);
const palette = [ const palette = [
"#1E4E52", "#1E4E52",
@ -69,6 +65,7 @@ export default function UpdateProductForm() {
id: prev.length + 1, id: prev.length + 1,
title: "", title: "",
images: [], images: [],
files: [],
}, },
]); ]);
}; };
@ -86,40 +83,195 @@ export default function UpdateProductForm() {
}; };
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false); const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
const [bannerFile, setBannerFile] = useState<File | null>(null);
const [uploadTarget, setUploadTarget] = useState<{ const [uploadTarget, setUploadTarget] = useState<{
type: "spec" | "color"; type: "spec" | "color" | "banner";
index: number; index?: number;
} | null>(null); } | null>(null);
const fileInputId = "file-upload-input"; const fileInputId = "file-upload-input";
const handleFileSelected = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const input = e.target;
const file = input.files?.[0];
if (!file || !uploadTarget) return; if (!file || !uploadTarget) return;
const reader = new FileReader(); const previewUrl = URL.createObjectURL(file);
reader.onload = () => {
const fileUrl = reader.result as string;
// ===================== SPEC =====================
if (uploadTarget.type === "spec") { if (uploadTarget.type === "spec") {
if (uploadTarget.index === undefined) return;
const index = uploadTarget.index;
// preview
setSpecs((prev) => { setSpecs((prev) => {
const updated = [...prev]; const updated = [...prev];
updated[uploadTarget.index].images.push(fileUrl); updated[index].images = [...updated[index].images, previewUrl];
return updated; 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.type === "color") {
if (uploadTarget.index === undefined) return;
const index = uploadTarget.index;
setColors((prev) => { setColors((prev) => {
const updated = [...prev]; const updated = [...prev];
updated[uploadTarget.index].preview = fileUrl; updated[index].preview = previewUrl;
return updated; 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);
}; };
reader.readAsDataURL(file); const formatRupiah = (value: string) =>
setIsUploadDialogOpen(false); "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 ( return (
@ -135,17 +287,64 @@ export default function UpdateProductForm() {
<div className="grid md:grid-cols-3 gap-4"> <div className="grid md:grid-cols-3 gap-4">
<div> <div>
<Label>Nama Produk *</Label> <Label>Nama Produk *</Label>
<Input defaultValue="JAECOO J7" /> <Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Masukkan nama produk"
/>
</div> </div>
<div> <div>
<Label>Tipe Varian *</Label> <Label>Tipe Varian *</Label>
<Input defaultValue="SHS" /> <Input
value={variant}
onChange={(e) => setVariant(e.target.value)}
placeholder="Masukkan varian"
/>
</div> </div>
<div> <div>
<Label>Harga Produk *</Label> <Label>Harga Produk *</Label>
<Input defaultValue="RP 599.000.000" /> <Input
value={price}
onChange={(e) => {
const rawValue = e.target.value.replace(/\D/g, "");
setPrice(formatRupiah(rawValue));
}}
placeholder="Masukkan harga"
/>
</div>
</div>
<div>
<Label className="font-semibold">Thumbnail Produk</Label>
<div className="mt-2 flex items-center gap-4">
<div className="w-[120px] h-[80px] rounded-lg overflow-hidden border bg-gray-100">
{thumbnail ? (
<Image
src={thumbnail}
alt="Thumbnail"
width={120}
height={80}
className="object-cover"
/>
) : (
<div className="flex items-center justify-center text-xs text-gray-400 h-full">
No Image
</div>
)}
</div>
<Button
type="button"
className="bg-teal-800 hover:bg-teal-900 text-white"
onClick={() => {
setUploadTarget({ type: "banner" });
setIsUploadDialogOpen(true);
}}
>
Upload Banner Baru
</Button>
</div> </div>
</div> </div>
@ -161,7 +360,15 @@ export default function UpdateProductForm() {
<Input <Input
placeholder="Contoh: Silver / #E2E2E2" placeholder="Contoh: Silver / #E2E2E2"
className="mt-1" className="mt-1"
defaultValue={item.name} value={item.name}
onChange={(e) => {
setColors((prev) => {
const updated = [...prev];
updated[index].name = e.target.value;
updated[index].colorSelected = e.target.value;
return updated;
});
}}
/> />
<div className="flex items-center gap-2 mt-3"> <div className="flex items-center gap-2 mt-3">
@ -183,6 +390,7 @@ export default function UpdateProductForm() {
setColors((prev) => { setColors((prev) => {
const updated = [...prev]; const updated = [...prev];
updated[index].colorSelected = colorCode; updated[index].colorSelected = colorCode;
updated[index].name = colorCode; // ✅ sinkron ke input
return updated; return updated;
}); });
}} }}
@ -238,9 +446,16 @@ export default function UpdateProductForm() {
Judul Spesifikasi {index + 1} Judul Spesifikasi {index + 1}
</Label> </Label>
<Input <Input
defaultValue={spec.title} value={spec.title}
placeholder="Masukkan Judul Spesifikasi" placeholder="Masukkan Judul Spesifikasi"
className="mt-1" className="mt-1"
onChange={(e) => {
setSpecs((prev) => {
const updated = [...prev];
updated[index].title = e.target.value;
return updated;
});
}}
/> />
<Label className="font-semibold text-sm mt-4 block"> <Label className="font-semibold text-sm mt-4 block">
@ -249,14 +464,42 @@ export default function UpdateProductForm() {
<div className="flex flex-wrap gap-4 mt-2"> <div className="flex flex-wrap gap-4 mt-2">
{spec.images.map((img, i) => ( {spec.images.map((img, i) => (
<div key={i} className="relative">
<Image <Image
key={i}
src={img} src={img}
width={120} width={120}
height={120} height={120}
alt="spec" alt="spec"
className="rounded-lg border object-cover" className="rounded-lg border object-cover"
/> />
{/* 🔴 TOMBOL HAPUS GAMBAR */}
<button
type="button"
onClick={() => {
// hapus preview
setSpecs((prev) => {
const updated = [...prev];
updated[index].images.splice(i, 1);
return updated;
});
// hapus file baru (jika ada)
setSpecFiles((prev) => {
const map = new Map(prev);
const files = map.get(index) || [];
files.splice(i, 1);
map.set(index, files);
return map;
});
}}
className="absolute -top-2 -right-2 bg-red-500 text-white
rounded-full w-6 h-6 flex items-center justify-center
text-xs hover:bg-red-600"
>
×
</button>
</div>
))} ))}
<Button <Button
@ -283,7 +526,10 @@ export default function UpdateProductForm() {
</Button> </Button>
</div> </div>
<Button className=" bg-teal-800 hover:bg-teal-900 text-white py-3"> <Button
onClick={handleSubmit}
className=" bg-teal-800 hover:bg-teal-900 text-white py-3"
>
Submit Submit
</Button> </Button>
</CardContent> </CardContent>

View File

@ -512,7 +512,7 @@ export default function ProductTable() {
</Button> </Button>
</Link> </Link>
{userRoleId !== "1" && ( {userRoleId !== "1" && (
<Link href={"/admin/product/update"}> <Link href={`/admin/product/update/${item.id}`}>
<Button <Button
className="text-[#0F6C75] hover:bg-transparent hover:underline p-0" className="text-[#0F6C75] hover:bg-transparent hover:underline p-0"
variant="ghost" variant="ghost"

View File

@ -56,6 +56,7 @@ import {
approvePromotion, approvePromotion,
rejectPromotion, rejectPromotion,
} from "@/service/promotion"; } from "@/service/promotion";
import PromoEditDialog from "../dialog/promo-edit-dialog";
const columns = [ const columns = [
{ name: "No", uid: "no" }, { name: "No", uid: "no" },
@ -95,6 +96,11 @@ export default function PromotionTable() {
const [categories, setCategories] = useState<any>([]); const [categories, setCategories] = useState<any>([]);
const [selectedCategories, setSelectedCategories] = useState<any>(""); const [selectedCategories, setSelectedCategories] = useState<any>("");
const [openDetail, setOpenDetail] = useState(false); const [openDetail, setOpenDetail] = useState(false);
const [openEditPromo, setOpenEditPromo] = useState(false);
const [selectedEditPromoId, setSelectedEditPromoId] = useState<number | null>(
null,
);
const [selectedPromo, setPromoDetail] = useState<any>(null); const [selectedPromo, setPromoDetail] = useState<any>(null);
const [selectedPromoId, setSelectedPromoId] = useState<number | null>(null); const [selectedPromoId, setSelectedPromoId] = useState<number | null>(null);
const [startDateValue, setStartDateValue] = useState({ const [startDateValue, setStartDateValue] = useState({
@ -474,7 +480,19 @@ export default function PromotionTable() {
<Eye className="w-4 h-4 mr-1" /> Lihat <Eye className="w-4 h-4 mr-1" /> Lihat
</Button> </Button>
{/* Tombol Edit */} {/* Tombol Edit */}
{userRoleId !== "1" && (
<Button
className="text-[#0F6C75] hover:bg-transparent hover:underline p-0"
variant="ghost"
size="sm"
onClick={() => {
setSelectedEditPromoId(item.id);
setOpenEditPromo(true);
}}
>
<CreateIconIon className="w-4 h-4 mr-1" /> Edit
</Button>
)}
{/* Tombol Approve & Reject - hanya untuk admin dan status pending */} {/* Tombol Approve & Reject - hanya untuk admin dan status pending */}
{/* {userRoleId === "1" && item.status_id === 1 && ( {/* {userRoleId === "1" && item.status_id === 1 && (
<> <>
@ -529,6 +547,15 @@ export default function PromotionTable() {
open={openDetail} open={openDetail}
onOpenChange={setOpenDetail} onOpenChange={setOpenDetail}
/> />
<PromoEditDialog
promoId={selectedEditPromoId}
open={openEditPromo}
onOpenChange={setOpenEditPromo}
onSuccess={() => {
setOpenEditPromo(false);
initState(); // refresh table setelah edit
}}
/>
{/* FOOTER PAGINATION */} {/* FOOTER PAGINATION */}
<div className="flex items-center justify-between px-6 py-3 border-t text-sm text-gray-600"> <div className="flex items-center justify-between px-6 py-3 border-t text-sm text-gray-600">

View File

@ -1,16 +1,24 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
images: { images: {
domains: [ remotePatterns: [
"mikulnews.com", {
"dev.mikulnews.com", protocol: "https",
"jaecoocihampelasbdg.com", hostname: "jaecoocihampelasbdg.com",
"jaecookelapagading.com", pathname: "/api/**",
},
{
protocol: "https",
hostname: "jaecookelapagading.com",
pathname: "/api/**",
},
], ],
}, },
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
experimental: { experimental: {
optimizePackageImports: ["@ckeditor/ckeditor5-react", "react-apexcharts"], optimizePackageImports: ["@ckeditor/ckeditor5-react", "react-apexcharts"],
}, },

3
package-lock.json generated
View File

@ -2834,6 +2834,7 @@
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.1.8", "version": "19.1.8",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
@ -2841,7 +2842,7 @@
}, },
"node_modules/@types/react-dom": { "node_modules/@types/react-dom": {
"version": "19.1.6", "version": "19.1.6",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"