feat: fixing create, detail, update for products
This commit is contained in:
parent
9bc0b301cb
commit
56b2d981af
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -8,6 +8,14 @@ import * as z from "zod";
|
|||
import { Upload, Plus, UploadCloud } from "lucide-react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
|
|
@ -30,8 +38,16 @@ export default function AddProductForm() {
|
|||
|
||||
const [selectedColor, setSelectedColor] = useState<string | null>(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 [file, setFile] = useState<File | null>(null);
|
||||
const router = useRouter();
|
||||
|
|
@ -43,9 +59,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,14 +80,26 @@ export default function AddProductForm() {
|
|||
setSpecs(updated);
|
||||
};
|
||||
|
||||
const handleSpecFileChange = (
|
||||
index: number,
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = e.target.files?.[0] || null;
|
||||
const updated = [...specs];
|
||||
updated[index].file = file;
|
||||
setSpecs(updated);
|
||||
const handleFileSelected = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || !uploadTarget) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const fileUrl = reader.result as string;
|
||||
|
||||
if (uploadTarget.type === "spec") {
|
||||
setSpecs((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[uploadTarget.index].images.push(fileUrl);
|
||||
updated[uploadTarget.index].files.push(file);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
setIsUploadDialogOpen(false);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof formSchema>) => {
|
||||
|
|
@ -99,17 +130,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 +370,7 @@ export default function AddProductForm() {
|
|||
</Label>
|
||||
|
||||
{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">
|
||||
Judul Spesifikasi {index + 1}
|
||||
</Label>
|
||||
|
|
@ -353,39 +385,58 @@ export default function AddProductForm() {
|
|||
Foto Spesifikasi {index + 1}
|
||||
</Label>
|
||||
|
||||
<label
|
||||
htmlFor={`spec-file-${index}`}
|
||||
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"
|
||||
>
|
||||
<Upload className="w-8 h-8 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500 text-center">
|
||||
Klik untuk upload gambar spesifikasi
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">PNG, JPG (max 5 MB)</p>
|
||||
|
||||
<input
|
||||
id={`spec-file-${index}`}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
className="hidden"
|
||||
onChange={(e) => handleSpecFileChange(index, e)}
|
||||
<div className="flex flex-wrap gap-4 mt-2">
|
||||
{spec.images.map((img, i) => (
|
||||
<div key={i} className="relative">
|
||||
<Image
|
||||
src={img}
|
||||
width={120}
|
||||
height={120}
|
||||
alt="spec"
|
||||
className="rounded-lg border object-cover"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{spec.file && (
|
||||
<p className="text-xs text-teal-700 mt-2">{spec.file.name}</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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>
|
||||
))}
|
||||
|
||||
{/* <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"
|
||||
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"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Tambahkan Spesifikasi Baru
|
||||
</button> */}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
|
@ -396,6 +447,52 @@ export default function AddProductForm() {
|
|||
</Button>
|
||||
</form>
|
||||
</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 cursor-pointer"
|
||||
onClick={() => document.getElementById(fileInputId)?.click()}
|
||||
>
|
||||
<Upload className="w-10 h-10 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-500">
|
||||
Klik untuk upload atau drag & drop
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">PNG, JPG (max 2 MB)</p>
|
||||
|
||||
<input
|
||||
id={fileInputId}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileSelected}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-3 pt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="bg-slate-200"
|
||||
onClick={() => setIsUploadDialogOpen(false)}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => document.getElementById(fileInputId)?.click()}
|
||||
className="bg-teal-800 hover:bg-teal-900 text-white"
|
||||
>
|
||||
Pilih File
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -299,11 +299,22 @@ export default function DetailProductForm(props: { isDetail: boolean }) {
|
|||
// colors
|
||||
if (data.colors?.length) {
|
||||
setColors(
|
||||
data.colors.map((color: string, index: number) => ({
|
||||
data.colors.map((color: any, index: number) => ({
|
||||
id: index + 1,
|
||||
name: color,
|
||||
preview: data.thumbnail_url,
|
||||
colorSelected: color,
|
||||
name: color.name || "",
|
||||
preview: color.image_url || data.thumbnail_url || "",
|
||||
colorSelected: color.name || null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// specifications
|
||||
if (data.specifications?.length) {
|
||||
setSpecs(
|
||||
data.specifications.map((spec: any, index: number) => ({
|
||||
id: index + 1,
|
||||
title: spec.title || "",
|
||||
images: spec.image_urls || [],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Map<number, File[]>>(new Map());
|
||||
|
||||
type ColorType = {
|
||||
id: number;
|
||||
|
|
@ -31,20 +35,12 @@ export default function UpdateProductForm() {
|
|||
colorSelected: string | null;
|
||||
};
|
||||
|
||||
const [colors, setColors] = useState<ColorType[]>([
|
||||
{
|
||||
id: 1,
|
||||
name: "",
|
||||
preview: "/car-1.png",
|
||||
colorSelected: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "",
|
||||
preview: "/car-2.png",
|
||||
colorSelected: null,
|
||||
},
|
||||
]);
|
||||
const [colors, setColors] = useState<ColorType[]>([]);
|
||||
const [colorFiles, setColorFiles] = useState<Map<number, File>>(new Map());
|
||||
const [thumbnail, setThumbnail] = useState<string>("");
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [variant, setVariant] = useState<string>("");
|
||||
const [price, setPrice] = useState<string>("");
|
||||
|
||||
const palette = [
|
||||
"#1E4E52",
|
||||
|
|
@ -69,6 +65,7 @@ export default function UpdateProductForm() {
|
|||
id: prev.length + 1,
|
||||
title: "",
|
||||
images: [],
|
||||
files: [],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
|
@ -107,6 +104,15 @@ export default function UpdateProductForm() {
|
|||
updated[uploadTarget.index].images.push(fileUrl);
|
||||
return updated;
|
||||
});
|
||||
// Store the file for upload
|
||||
if (file) {
|
||||
setSpecFiles((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const existingFiles = newMap.get(uploadTarget.index) || [];
|
||||
newMap.set(uploadTarget.index, [...existingFiles, file]);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadTarget.type === "color") {
|
||||
|
|
@ -115,6 +121,14 @@ export default function UpdateProductForm() {
|
|||
updated[uploadTarget.index].preview = fileUrl;
|
||||
return updated;
|
||||
});
|
||||
// Store the file for upload
|
||||
if (file) {
|
||||
setColorFiles((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(uploadTarget.index, file);
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -122,6 +136,127 @@ export default function UpdateProductForm() {
|
|||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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));
|
||||
|
||||
// Specification images (only new files if uploaded)
|
||||
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 (
|
||||
<>
|
||||
<Card className="w-full border-none shadow-md">
|
||||
|
|
@ -135,17 +270,51 @@ export default function UpdateProductForm() {
|
|||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label>Nama Produk *</Label>
|
||||
<Input defaultValue="JAECOO J7" />
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Masukkan nama produk"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Tipe Varian *</Label>
|
||||
<Input defaultValue="SHS" />
|
||||
<Input
|
||||
value={variant}
|
||||
onChange={(e) => setVariant(e.target.value)}
|
||||
placeholder="Masukkan varian"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
|
|
@ -161,7 +330,15 @@ export default function UpdateProductForm() {
|
|||
<Input
|
||||
placeholder="Contoh: Silver / #E2E2E2"
|
||||
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">
|
||||
|
|
@ -283,7 +460,10 @@ export default function UpdateProductForm() {
|
|||
</Button>
|
||||
</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
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -512,7 +512,7 @@ export default function ProductTable() {
|
|||
</Button>
|
||||
</Link>
|
||||
{userRoleId !== "1" && (
|
||||
<Link href={"/admin/product/update"}>
|
||||
<Link href={`/admin/product/update/${item.id}`}>
|
||||
<Button
|
||||
className="text-[#0F6C75] hover:bg-transparent hover:underline p-0"
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ export async function createProduct(data: any) {
|
|||
|
||||
export async function updateProduct(data: any, id: any) {
|
||||
const pathUrl = `/products/${id}`;
|
||||
return await httpPutInterceptor(pathUrl, data);
|
||||
const headers = {
|
||||
"content-type": "multipart/form-data",
|
||||
};
|
||||
return await httpPutInterceptor(pathUrl, data, headers);
|
||||
}
|
||||
|
||||
export async function getProductDataById(id: any) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue