fix:CRUD product,promo
This commit is contained in:
parent
83eae371a6
commit
01df3f41d4
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,7 +514,12 @@ 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>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-4 gap-6 border-2 rounded-lg border-black p-3 ">
|
{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">
|
||||||
{colors.map((item, index) => (
|
{colors.map((item, index) => (
|
||||||
<div key={item.id} className="space-y-3">
|
<div key={item.id} className="space-y-3">
|
||||||
<Label className="text-sm text-gray-500">
|
<Label className="text-sm text-gray-500">
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue