This is page 2 of 2. Use http://codebase.md/ralfarishi/cheese-stick-koe-dashboard?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitignore ├── components.json ├── eslint.config.mjs ├── jsconfig.json ├── middleware.js ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public │ ├── file.svg │ ├── globe.svg │ ├── logo.png │ ├── next.svg │ ├── qris.png │ ├── vercel.svg │ └── window.svg ├── README.md └── src ├── app │ ├── _components │ │ └── LoginForm.jsx │ ├── api │ │ └── logout │ │ └── route.js │ ├── dashboard │ │ ├── invoices │ │ │ ├── _components │ │ │ │ ├── DeleteInvoiceModal.jsx │ │ │ │ ├── InvoiceDownloadModal.jsx │ │ │ │ ├── InvoicePreview.jsx │ │ │ │ └── StatusCombobox.jsx │ │ │ ├── [invoiceNumber] │ │ │ │ └── page.jsx │ │ │ ├── create │ │ │ │ ├── _components │ │ │ │ │ ├── ProductsCombobox.jsx │ │ │ │ │ └── SizeCombobox.jsx │ │ │ │ ├── CreateInvoicePage.jsx │ │ │ │ └── page.js │ │ │ ├── InvoicePage.jsx │ │ │ ├── page.js │ │ │ ├── Table.jsx │ │ │ └── UpdateInvoiceForm.jsx │ │ ├── layout.jsx │ │ ├── page.jsx │ │ ├── products │ │ │ ├── _components │ │ │ │ ├── ProductDeleteModal.jsx │ │ │ │ ├── ProductEditModal.jsx │ │ │ │ ├── ProductModal.jsx │ │ │ │ ├── ProductModalButton.jsx │ │ │ │ └── ProductTable.jsx │ │ │ ├── page.js │ │ │ └── ProductPage.jsx │ │ └── size-pricing │ │ ├── _components │ │ │ ├── AddSizeButton.jsx │ │ │ ├── AddSizeModal.jsx │ │ │ ├── DeleteSizeModal.jsx │ │ │ ├── EditSizeModal.jsx │ │ │ └── Table.jsx │ │ ├── page.js │ │ └── SizePage.jsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.js │ ├── not-found.js │ ├── page.js │ └── unauthorized.js ├── components │ ├── dashboard │ │ ├── DatePicker.jsx │ │ ├── Modal.jsx │ │ └── Sidebar.jsx │ └── ui │ ├── badge.jsx │ ├── button.jsx │ ├── calendar.jsx │ ├── card.jsx │ ├── command.jsx │ ├── dialog.jsx │ ├── drawer.jsx │ ├── input.jsx │ ├── label.jsx │ ├── popover.jsx │ ├── radio-group.jsx │ ├── select.jsx │ └── sonner.jsx └── lib ├── actions │ ├── invoice │ │ ├── deleteInvoice.js │ │ ├── getAll.js │ │ ├── getInvoiceByNumber.js │ │ ├── getInvoiceWithItem.js │ │ ├── submitInvoice.js │ │ └── updateInvoice.js │ ├── products │ │ ├── addProduct.js │ │ ├── deleteProduct.js │ │ ├── getAllProducts.js │ │ └── updateProduct.js │ └── size-price │ ├── addSize.js │ ├── deleteSize.js │ ├── getAll.js │ └── updateSize.js ├── exportToPng.js ├── supabaseBrowser.js ├── supabaseServer.js ├── utils.js └── verifySession.js ``` # Files -------------------------------------------------------------------------------- /src/app/dashboard/invoices/_components/InvoicePreview.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { forwardRef, useEffect, useState } from "react"; 4 | 5 | import Image from "next/image"; 6 | 7 | import { getAllProducts } from "@/lib/actions/products/getAllProducts"; 8 | import { getAllSizePrice } from "@/lib/actions/size-price/getAll"; 9 | 10 | import { cn, formatDateFilename, toTitleCase } from "@/lib/utils"; 11 | 12 | const InvoicePreview = forwardRef( 13 | ( 14 | { invoice, invoiceItems, shippingType, onReady, onDataReady, isDownloadVersion = false }, 15 | ref 16 | ) => { 17 | const [products, setProducts] = useState([]); 18 | const [sizes, setSizes] = useState([]); 19 | const [items, setItems] = useState([]); 20 | 21 | useEffect(() => { 22 | const fetchData = async () => { 23 | const { data: productsData } = await getAllProducts(); 24 | const { data: sizeData } = await getAllSizePrice(); 25 | 26 | setProducts(productsData || []); 27 | setSizes(sizeData || []); 28 | }; 29 | 30 | fetchData(); 31 | }, []); 32 | 33 | useEffect(() => { 34 | if (invoiceItems?.length && products.length && sizes.length) { 35 | const mappedItems = invoiceItems.map((item) => { 36 | const product = products.find((p) => p.id === item.productId); 37 | const size = sizes.find((s) => s.id === item.sizePriceId); 38 | const price = size?.price || 0; 39 | const discount = item.discountAmount || 0; 40 | const quantity = item.quantity; 41 | 42 | return { 43 | productName: product?.name || "Unknown", 44 | sizeName: size?.size || "Unknown", 45 | quantity, 46 | price, 47 | discountAmount: discount, 48 | total: quantity * price - discount, 49 | }; 50 | }); 51 | 52 | setItems(mappedItems); 53 | 54 | onDataReady?.(true); 55 | } else { 56 | onDataReady?.(false); 57 | } 58 | }, [invoiceItems, products, sizes]); 59 | 60 | useEffect(() => { 61 | if (!items.length) return; 62 | 63 | const timer = setTimeout(() => { 64 | onReady?.(); 65 | }, 0); 66 | 67 | return () => clearTimeout(timer); 68 | }, [items]); 69 | 70 | const subtotal = items.reduce((acc, item) => acc + item.total, 0); 71 | const discount = invoice.discount || 0; 72 | 73 | const discountPercent = subtotal > 0 ? (discount / subtotal) * 100 : 0; 74 | 75 | const TOTAL_GAP_ROWS = 10; 76 | const gapRows = Math.max(0, TOTAL_GAP_ROWS - items.length); 77 | 78 | return ( 79 | <> 80 | <style jsx global>{` 81 | @import url("https://fonts.googleapis.com/css2?family=Pattaya&family=Poppins:wght@400;700&display=swap"); 82 | 83 | .invoice-content { 84 | font-family: "Poppins", sans-serif; 85 | } 86 | 87 | .thanks-msg { 88 | font-family: "Pattaya", cursive; 89 | } 90 | 91 | .invoice-content * { 92 | box-sizing: border-box; 93 | } 94 | 95 | .invoice-content table td, 96 | .invoice-content table th { 97 | padding: 8px 12px; /* ~px-3 py-2 */ 98 | white-space: nowrap; 99 | } 100 | `}</style> 101 | 102 | <div 103 | ref={ref} 104 | className={cn( 105 | "border border-[#6D2315] font-sans text-sm text-gray-900 invoice-content", 106 | isDownloadVersion 107 | ? "w-[1080px] p-3 overflow-visible" 108 | : "w-full md:w-[1080px] overflow-x-auto p-4" 109 | )} 110 | > 111 | <div 112 | className={cn("flex w-full", isDownloadVersion ? "flex-row" : "flex-col md:flex-row")} 113 | > 114 | {/* LEFT SECTION */} 115 | <div 116 | className={cn( 117 | "pr-4 border-r border-[#6D2315] space-y-6", 118 | isDownloadVersion ? "w-1/3" : "w-full md:w-1/3" 119 | )} 120 | > 121 | <div className="flex justify-center mb-4"> 122 | <div className="w-24 h-24 relative"> 123 | <Image src="/logo.png" alt="Logo" fill className="object-cover rounded-full" /> 124 | </div> 125 | </div> 126 | 127 | <h2 className="text-center font-bold text-[#6b1d1d] uppercase text-lg pb-7"> 128 | cheese stick koe 129 | </h2> 130 | 131 | <div> 132 | <h4 className="text-[#6b1d1d] font-semibold text-xs mb-1 uppercase"> 133 | diberikan kepada 134 | </h4> 135 | <p className="text-sm">{toTitleCase(invoice?.buyerName)}</p> 136 | </div> 137 | 138 | <div> 139 | <h4 className="text-[#6b1d1d] font-semibold text-xs mb-1 uppercase">detail bank</h4> 140 | <div className="text-sm space-y-2"> 141 | <div> 142 | <span className="font-semibold uppercase">bri</span> 143 | <br /> 144 | Ermi Sayekti Endahwati 145 | <br /> 146 | 0122-01-012734-53-8 147 | </div> 148 | <div> 149 | <span className="font-semibold uppercase">bca</span> 150 | <br /> 151 | Ermi Sayekti Endahwati 152 | <br /> 153 | 524-5031-928 154 | </div> 155 | </div> 156 | </div> 157 | 158 | <figure className="space-y-2"> 159 | {/* Image */} 160 | <figcaption className="text-xs text-left font-bold italic"> 161 | QRIS a.n Cheese Stick Koe 162 | </figcaption> 163 | 164 | <div className="flex justify-center mt-5"> 165 | <div className="relative w-38 h-38"> 166 | <Image src="/qris.png" alt="Icon" fill className="object-contain" /> 167 | </div> 168 | </div> 169 | 170 | {/* Caption */} 171 | </figure> 172 | 173 | {/* Thank You Text */} 174 | <div className="text-center pt-5 font-bold text-2xl text-[#6b1d1d] thanks-msg uppercase"> 175 | terima kasih 176 | </div> 177 | </div> 178 | 179 | {/* RIGHT SECTION */} 180 | <div className="md:w-2/3 w-full pl-6 space-y-6 mt-6 md:mt-0"> 181 | <div className="text-center border-b border-[#6D2315] pb-2"> 182 | <h1 className="font-bold text-lg uppercase">invoice pembayaran</h1> 183 | <div className="flex justify-between text-xs mt-1"> 184 | <span>Invoice No. {invoice?.invoiceNumber}</span> 185 | <span className="whitespace-nowrap"> 186 | {formatDateFilename(invoice?.invoiceDate)} 187 | </span> 188 | </div> 189 | </div> 190 | 191 | <div 192 | className={cn( 193 | !isDownloadVersion && "overflow-x-auto max-w-full", 194 | isDownloadVersion && "overflow-hidden" 195 | )} 196 | > 197 | <table 198 | className="text-left text-sm" 199 | style={{ 200 | border: "1.5px solid #6D2315", 201 | tableLayout: "auto", 202 | width: "100%", 203 | }} 204 | > 205 | <thead 206 | style={{ backgroundColor: "white", borderBottom: "1.5px solid #6D2315" }} 207 | className="uppercase" 208 | > 209 | <tr> 210 | <th className="px-2.5 py-2 whitespace-nowrap">item</th> 211 | <th className="px-2.5 py-2 whitespace-nowrap">ukuran</th> 212 | <th className="px-2.5 py-2 whitespace-nowrap">jml</th> 213 | <th className="px-2.5 py-2 whitespace-nowrap">harga</th> 214 | <th className="px-2.5 py-2 whitespace-nowrap">diskon</th> 215 | <th className="px-2.5 py-2 whitespace-nowrap">total</th> 216 | </tr> 217 | </thead> 218 | <tbody> 219 | {items.map((item, i) => ( 220 | <tr key={i}> 221 | <td className="px-2.5 py-2 whitespace-nowrap">{item.productName}</td> 222 | <td className="px-2.5 py-2 whitespace-nowrap text-center"> 223 | {item.sizeName} 224 | </td> 225 | <td className="px-2.5 py-2 whitespace-nowrap text-center"> 226 | {item.quantity} 227 | </td> 228 | <td className="px-2.5 py-2 whitespace-nowrap">{`Rp ${item.price.toLocaleString( 229 | "id-ID" 230 | )}`}</td> 231 | <td 232 | className={`px-2.5 py-2 whitespace-nowrap text-center ${ 233 | item.discountAmount > 0 ? "text-green-600" : "" 234 | }`} 235 | > 236 | {item.discountAmount 237 | ? `-Rp ${item.discountAmount.toLocaleString("id-ID")}` 238 | : "-"} 239 | </td> 240 | <td className="p-1 whitespace-nowrap"> 241 | {item.total ? `Rp ${item.total.toLocaleString("id-ID")}` : "-"} 242 | </td> 243 | </tr> 244 | ))} 245 | 246 | {Array.from({ length: gapRows }).map((_, idx) => ( 247 | <tr key={`gap-${idx}`}> 248 | <td> </td> 249 | <td></td> 250 | <td></td> 251 | <td></td> 252 | <td></td> 253 | <td></td> 254 | <td></td> 255 | </tr> 256 | ))} 257 | 258 | {/* Subtotal */} 259 | <tr style={{ borderTop: "1.5px solid #6D2315" }}> 260 | <td colSpan="5" className="px-2.5 py-2 text-right uppercase"> 261 | Sub Total : 262 | </td> 263 | <td className="px-2.5 py-2 whitespace-nowrap"> 264 | Rp {subtotal.toLocaleString("id-ID")} 265 | </td> 266 | </tr> 267 | 268 | {/* Diskon */} 269 | {invoice?.discount > 0 && ( 270 | <tr className="text-green-500"> 271 | <td colSpan="5" className="px-2.5 py-2 text-right uppercase"> 272 | diskon ({discountPercent.toFixed(2)}%) : 273 | </td> 274 | <td className="px-2.5 py-2 whitespace-nowrap"> 275 | -Rp{" "} 276 | {typeof invoice.discount === "number" 277 | ? invoice.discount.toLocaleString("id-ID") 278 | : "0"} 279 | </td> 280 | </tr> 281 | )} 282 | 283 | {/* Ongkir */} 284 | <tr> 285 | <td colSpan="5" className="px-2.5 py-2 text-right uppercase"> 286 | {shippingType ? `ongkir (${shippingType}) :` : "ongkir :"} 287 | </td> 288 | <td className="px-2.5 py-2 whitespace-nowrap"> 289 | Rp {invoice?.shipping?.toLocaleString("id-ID")} 290 | </td> 291 | </tr> 292 | 293 | {/* Total */} 294 | <tr style={{ borderBottom: "1.5px solid #6D2315" }}> 295 | <td 296 | colSpan="5" 297 | className="px-2.5 py-2 text-right font-bold text-red-700 uppercase" 298 | > 299 | jumlah yang harus dibayar : 300 | </td> 301 | <td className="px-2.5 py-2 font-bold text-red-700 whitespace-nowrap"> 302 | Rp{" "} 303 | {typeof invoice.totalPrice === "number" 304 | ? invoice.totalPrice.toLocaleString("id-ID") 305 | : "0"} 306 | </td> 307 | </tr> 308 | </tbody> 309 | </table> 310 | </div> 311 | 312 | {/* Disclaimer */} 313 | <div className="text-red-600 pt-4 border-t border-gray-200"> 314 | <span className="font-semibold text-sm">*Disclaimer</span> 315 | <br /> 316 | <span className="text-black text-sm"> 317 | Segala kerusakan yang terjadi selama pengiriman menjadi tanggung jawab pihak 318 | ekspedisi. Namun, kami siap membantu proses klaim ke pihak ekspedisi apabila 319 | terjadi kendala selama pengiriman. 320 | </span> 321 | </div> 322 | </div> 323 | </div> 324 | </div> 325 | </> 326 | ); 327 | } 328 | ); 329 | 330 | export default InvoicePreview; 331 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/create/CreateInvoicePage.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | import Link from "next/link"; 6 | 7 | import { Controller, useForm } from "react-hook-form"; 8 | 9 | import { supabaseBrowser } from "@/lib/supabaseBrowser"; 10 | 11 | import { Input } from "@/components/ui/input"; 12 | import { Button } from "@/components/ui/button"; 13 | import { Label } from "@/components/ui/label"; 14 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 15 | 16 | import DatePicker from "@/components/dashboard/DatePicker"; 17 | 18 | import ProductCombobox from "./_components/ProductsCombobox"; 19 | import SizeCombobox from "./_components/SizeCombobox"; 20 | 21 | import { ChevronRight, Trash2 } from "lucide-react"; 22 | import { toast } from "sonner"; 23 | 24 | import { getAllProducts } from "@/lib/actions/products/getAllProducts"; 25 | import { getAllSizePrice } from "@/lib/actions/size-price/getAll"; 26 | import { submitInvoice } from "@/lib/actions/invoice/submitInvoice"; 27 | import { calculateDiscountAmount, calculateDiscountPercent } from "@/lib/utils"; 28 | 29 | export default function CreateInvoicePage() { 30 | const supabase = supabaseBrowser(); 31 | const [user, setUser] = useState(null); 32 | 33 | const [products, setProducts] = useState([]); 34 | const [sizes, setSizes] = useState([]); 35 | 36 | const [invoiceDate, setInvoiceDate] = useState(new Date().toISOString()); 37 | 38 | const [lastInvoiceNumber, setLastInvoiceNumber] = useState(null); 39 | 40 | const [shippingPrice, setShippingPrice] = useState(0); 41 | 42 | const { 43 | control, 44 | handleSubmit, 45 | formState: { errors }, 46 | reset, 47 | } = useForm({ 48 | defaultValues: { 49 | invoiceNumber: "", 50 | buyerName: "", 51 | }, 52 | mode: "onChange", 53 | }); 54 | 55 | const resetForm = () => { 56 | setInvoiceDate(new Date().toISOString()); 57 | setShippingPrice(0); 58 | setDiscountInput(0); 59 | setItems([createEmptyItem()]); 60 | }; 61 | 62 | const createEmptyItem = () => ({ 63 | productId: "", 64 | sizePriceId: "", 65 | quantity: 1, 66 | price: 0, 67 | discountMode: "amount" | "percent", 68 | discountInput: "", 69 | discountAmount: 0, 70 | total: 0, 71 | }); 72 | 73 | const [items, setItems] = useState([createEmptyItem()]); 74 | 75 | // general discount 76 | const [discountMode, setDiscountMode] = useState("amount"); 77 | const [discountInput, setDiscountInput] = useState(0); 78 | 79 | useEffect(() => { 80 | const fetchInitialData = async () => { 81 | const [{ data: userData }, { data: lastInvoice }] = await Promise.all([ 82 | supabase.auth.getUser(), 83 | supabase 84 | .from("Invoice") 85 | .select("invoiceNumber, invoiceDate") 86 | .order("invoiceDate", { ascending: false }) 87 | .limit(1) 88 | .single(), 89 | ]); 90 | 91 | if (userData?.user) setUser(userData.user); 92 | if (lastInvoice) setLastInvoiceNumber(lastInvoice.invoiceNumber); 93 | }; 94 | 95 | fetchInitialData(); 96 | }, []); 97 | 98 | const addItem = () => { 99 | setItems([...items, createEmptyItem()]); 100 | }; 101 | 102 | const removeItem = (index) => { 103 | setItems((prev) => prev.filter((_, i) => i !== index)); 104 | }; 105 | 106 | useEffect(() => { 107 | const fetchOptions = async () => { 108 | const [{ data: prods }, { data: szs }] = await Promise.all([ 109 | getAllProducts(), 110 | getAllSizePrice(), 111 | ]); 112 | 113 | setProducts(prods || []); 114 | setSizes(szs || []); 115 | }; 116 | 117 | fetchOptions(); 118 | }, []); 119 | 120 | // calculate each item total and subtotal 121 | const updateItemField = (item, field, value, mode = null) => { 122 | if (field === "sizePriceId") { 123 | const selectedSize = sizes.find((s) => s.id === value); 124 | item.sizePriceId = value; 125 | item.price = selectedSize?.price || 0; 126 | } else if (field === "quantity") { 127 | const parsed = parseInt(value, 10); 128 | item.quantity = isNaN(parsed) || parsed < 1 ? 1 : parsed; 129 | } else if (field === "price") { 130 | const parsed = parseInt(value, 10); 131 | item.price = isNaN(parsed) ? 0 : parsed; 132 | } else if (field === "discountMode") { 133 | item.discountMode = value; 134 | } else if (field === "discountInput") { 135 | item.discountInput = value; 136 | item.discountMode = mode; 137 | } else { 138 | item[field] = value; 139 | } 140 | 141 | const qty = item.quantity || 0; 142 | const price = item.price || 0; 143 | const rawTotal = qty * price; 144 | 145 | const discountAmount = calculateDiscountAmount({ 146 | quantity: item.quantity, 147 | price: item.price, 148 | discountInput: item.discountInput, 149 | discountMode: item.discountMode, 150 | }); 151 | 152 | item.discountAmount = discountAmount; 153 | item.total = rawTotal - discountAmount; 154 | }; 155 | 156 | const handleItemChange = (index, field, value, mode = null) => { 157 | const updatedItems = [...items]; 158 | const item = updatedItems[index]; 159 | 160 | updateItemField(item, field, value, mode); 161 | setItems(updatedItems); 162 | }; 163 | 164 | const subtotal = items.reduce((sum, item) => sum + item.total, 0); 165 | 166 | const discountAmount = 167 | discountMode === "percent" 168 | ? Math.round(((parseFloat(discountInput) || 0) / 100) * subtotal) 169 | : parseInt(discountInput) || 0; 170 | 171 | const discountPercent = 172 | discountMode === "amount" 173 | ? ((parseInt(discountInput) || 0) / subtotal) * 100 174 | : parseFloat(discountInput) || 0; 175 | 176 | const totalPrice = subtotal + (parseInt(shippingPrice) || 0) - discountAmount; 177 | 178 | const onSubmit = async (data) => { 179 | if (!user) { 180 | toast.error("User not log in"); 181 | return; 182 | } 183 | 184 | const isInvalid = items.some((item) => !item.productId || !item.sizePriceId); 185 | 186 | if (isInvalid) { 187 | toast.error("You must add product and size before submitting!"); 188 | return; 189 | } 190 | 191 | const res = await submitInvoice({ 192 | invoiceNumber: data.invoiceNumber, 193 | buyerName: data.buyerName.trim().toLowerCase(), 194 | invoiceDate, 195 | shippingPrice, 196 | discountAmount, 197 | totalPrice, 198 | items, 199 | user, 200 | }); 201 | 202 | if (res.error) { 203 | toast.error(res.error); 204 | } else { 205 | toast.success(res.message); 206 | 207 | reset({ 208 | invoiceNumber: "", 209 | buyerName: "", 210 | }); 211 | 212 | resetForm(); 213 | } 214 | }; 215 | 216 | return ( 217 | <section className="w-full px-4 py-6 bg-[#fffaf0]"> 218 | <div className="bg-white rounded-xl shadow-md p-6 space-y-6 border border-[#f4e3d3]"> 219 | {/* Breadcrumbs (Mobile Only) */} 220 | <div className="block md:hidden text-sm text-gray-500 mb-4"> 221 | <nav className="flex items-center space-x-1"> 222 | <Link className="text-gray-400" href="/dashboard/invoices"> 223 | List Invoice 224 | </Link> 225 | <ChevronRight className="w-4 h-4 text-gray-400" /> 226 | <span className="text-gray-700 font-medium">Create Invoice</span> 227 | </nav> 228 | </div> 229 | 230 | <Card className="border-0 shadow-none"> 231 | <CardHeader className="text-center mb-2"> 232 | <CardTitle className="font-bold text-3xl text-[#6D2315]">INVOICE</CardTitle> 233 | </CardHeader> 234 | 235 | <CardContent> 236 | <form onSubmit={handleSubmit(onSubmit)} className="space-y-8"> 237 | {/* Basic Info */} 238 | <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> 239 | <div> 240 | <Label className="py-2 block text-sm text-gray-700">Invoice Number</Label> 241 | <Controller 242 | name="invoiceNumber" 243 | control={control} 244 | rules={{ 245 | required: "Invoice Number is required!", 246 | pattern: { 247 | value: /^\d{4}$/, 248 | message: "Invoice Number must be exactly 4 digits (0-9)", 249 | }, 250 | }} 251 | render={({ field }) => ( 252 | <Input 253 | {...field} 254 | placeholder={`Nomor invoice terakhir: ${lastInvoiceNumber || "0000"}`} 255 | maxLength={4} 256 | required 257 | /> 258 | )} 259 | /> 260 | {errors.invoiceNumber && ( 261 | <p role="alert" className="text-sm text-red-500"> 262 | {errors.invoiceNumber.message} 263 | </p> 264 | )} 265 | </div> 266 | <div> 267 | <Label className="py-2 block text-sm text-gray-700">Buyer Name</Label> 268 | <Controller 269 | name="buyerName" 270 | control={control} 271 | rules={{ 272 | required: "Buyer Name is required!", 273 | pattern: { 274 | value: /^[A-Za-z\s]+$/, 275 | message: "Buyer Name must contain only letters and spaces", 276 | }, 277 | }} 278 | render={({ field }) => <Input {...field} placeholder="Nama pembeli" required />} 279 | /> 280 | {errors.buyerName && ( 281 | <p role="alert" className="text-sm text-red-500"> 282 | {errors.buyerName.message} 283 | </p> 284 | )} 285 | </div> 286 | <div> 287 | <Label className="py-2 block text-sm text-gray-700">Invoice Date</Label> 288 | <DatePicker invoiceDate={invoiceDate} setInvoiceDate={setInvoiceDate} /> 289 | </div> 290 | </div> 291 | 292 | {/* Item List */} 293 | <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> 294 | {items.map((item, index) => ( 295 | <div key={index}> 296 | <h3 className="text-sm font-medium text-[#6D2315] mb-1">Item {index + 1}</h3> 297 | 298 | <div className="bg-[#fffefb] border border-[#fceee4] rounded-md p-4 space-y-3"> 299 | {/* Item Select */} 300 | <div> 301 | <Label className="text-sm text-gray-700 mb-1 block">Item</Label> 302 | <ProductCombobox 303 | products={products} 304 | value={item.productId} 305 | onChange={(val) => handleItemChange(index, "productId", val)} 306 | /> 307 | </div> 308 | 309 | {/* Size & Qty */} 310 | <div className="grid grid-cols-1 md:grid-cols-2 gap-2"> 311 | <div> 312 | <Label className="text-sm text-gray-700 mb-1 block">Size</Label> 313 | <SizeCombobox 314 | sizes={sizes} 315 | value={item.sizePriceId} 316 | onChange={(val, price) => { 317 | handleItemChange(index, "sizePriceId", val); 318 | handleItemChange(index, "price", price); 319 | }} 320 | /> 321 | </div> 322 | <div> 323 | <Label className="text-sm text-gray-700 mb-1 block">Qty</Label> 324 | {/* desktop */} 325 | <div className="hidden md:block"> 326 | <Input 327 | type="number" 328 | value={item.quantity} 329 | onChange={(e) => handleItemChange(index, "quantity", e.target.value)} 330 | required 331 | /> 332 | </div> 333 | 334 | <div className="flex items-center gap-2 md:hidden"> 335 | <Input 336 | type="number" 337 | value={item.quantity} 338 | onChange={(e) => handleItemChange(index, "quantity", e.target.value)} 339 | className="w-15 text-center" 340 | /> 341 | <button 342 | type="button" 343 | className="w-15 px-2 py-1 border rounded bg-rose-500 text-white" 344 | onClick={() => 345 | handleItemChange( 346 | index, 347 | "quantity", 348 | Math.max(1, Number(item.quantity) - 1) 349 | ) 350 | } 351 | > 352 | - 353 | </button> 354 | <button 355 | type="button" 356 | className="w-15 px-2 py-1 border rounded bg-emerald-500 text-white" 357 | onClick={() => 358 | handleItemChange(index, "quantity", Number(item.quantity) + 1) 359 | } 360 | > 361 | + 362 | </button> 363 | </div> 364 | </div> 365 | </div> 366 | 367 | {/* Price & Total */} 368 | <div className="grid grid-cols-2 gap-2"> 369 | <div> 370 | <Label className="text-sm text-gray-700 mb-1 block">Price</Label> 371 | <Input 372 | type="number" 373 | value={item.price} 374 | disabled 375 | className="bg-gray-100" 376 | /> 377 | </div> 378 | <div> 379 | <Label className="text-sm text-gray-700 mb-1 block">Total</Label> 380 | <Input 381 | value={item.total.toLocaleString("id-ID")} 382 | disabled 383 | className="bg-gray-100" 384 | /> 385 | </div> 386 | </div> 387 | 388 | {/* Discount Each Item*/} 389 | <div> 390 | <Label className="text-sm text-gray-700 mb-1 block"> 391 | Discount (Optional) 392 | </Label> 393 | <div className="grid grid-cols-2 gap-2"> 394 | <div> 395 | <Label className="text-xs text-gray-500">Percent (%)</Label> 396 | <Input 397 | type="number" 398 | placeholder="%" 399 | min={0} 400 | max={100} 401 | step="any" 402 | value={ 403 | item.discountMode === "percent" 404 | ? item.discountInput 405 | : calculateDiscountPercent(item) 406 | } 407 | onChange={(e) => 408 | handleItemChange(index, "discountInput", e.target.value, "percent") 409 | } 410 | /> 411 | </div> 412 | <div> 413 | <Label className="text-xs text-gray-500">Amount (Rp)</Label> 414 | <Input 415 | type="number" 416 | placeholder="Rp" 417 | min={0} 418 | value={ 419 | item.discountMode === "amount" 420 | ? item.discountInput 421 | : item.discountAmount 422 | } 423 | onChange={(e) => 424 | handleItemChange(index, "discountInput", e.target.value, "amount") 425 | } 426 | /> 427 | </div> 428 | </div> 429 | </div> 430 | 431 | {/* Delete button */} 432 | <div className="flex justify-end"> 433 | <Button 434 | type="button" 435 | variant="destructive" 436 | onClick={() => removeItem(index)} 437 | className="h-9 px-3" 438 | > 439 | <Trash2 className="w-4 h-4" /> 440 | </Button> 441 | </div> 442 | </div> 443 | </div> 444 | ))} 445 | 446 | {/* Add Item Button */} 447 | <div className="md:col-span-3"> 448 | <Button 449 | type="button" 450 | onClick={addItem} 451 | className="mt-2 bg-[#6D2315] hover:bg-[#591c10] text-white" 452 | > 453 | + Add Item 454 | </Button> 455 | </div> 456 | </div> 457 | 458 | <div className="grid grid-cols-1 md:grid-cols-12 gap-4"> 459 | {/* Discount General */} 460 | <div className="md:col-span-4"> 461 | <div className="bg-[#fffaf0] border border-[#f4e3d3] rounded-md px-4 py-3 h-full"> 462 | <Label className="block text-sm text-gray-700 mb-2">Discount (Optional)</Label> 463 | <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> 464 | <div> 465 | <Label className="text-xs text-gray-500 mb-1 block">Percent (%)</Label> 466 | <Input 467 | type="number" 468 | min={0} 469 | max={100} 470 | step="any" 471 | value={ 472 | discountMode === "percent" 473 | ? discountInput 474 | : discountPercent.toFixed(2) || 0 475 | } 476 | onChange={(e) => { 477 | setDiscountMode("percent"); 478 | setDiscountInput(e.target.value); 479 | }} 480 | /> 481 | </div> 482 | 483 | <div> 484 | <Label className="text-xs text-gray-500 mb-1 block">Amount (Rp)</Label> 485 | <Input 486 | type="number" 487 | min={0} 488 | value={discountMode === "amount" ? discountInput : discountAmount} 489 | onChange={(e) => { 490 | setDiscountMode("amount"); 491 | setDiscountInput(e.target.value); 492 | }} 493 | /> 494 | </div> 495 | </div> 496 | </div> 497 | </div> 498 | 499 | <div className="md:col-span-8 hidden md:block"></div> 500 | 501 | {/* Subtotal */} 502 | <div className="md:col-span-4"> 503 | <Label className="py-2 block text-sm text-gray-700">Subtotal</Label> 504 | <Input 505 | value={subtotal.toLocaleString("id-ID")} 506 | disabled 507 | className="bg-gray-100" 508 | /> 509 | </div> 510 | 511 | {/* Shipping */} 512 | <div className="md:col-span-4"> 513 | <Label className="py-2 block text-sm text-gray-700">Shipping Price</Label> 514 | <Input 515 | type="number" 516 | value={shippingPrice} 517 | onChange={(e) => setShippingPrice(e.target.value)} 518 | /> 519 | </div> 520 | 521 | {/* Total */} 522 | <div className="md:col-span-4"> 523 | <Label className="py-2 block text-sm text-gray-700">Total Price</Label> 524 | <Input 525 | value={totalPrice.toLocaleString("id-ID")} 526 | disabled 527 | className="bg-gray-100" 528 | /> 529 | </div> 530 | </div> 531 | 532 | <Button type="submit" className="w-full bg-[#6D2315] hover:bg-[#591c10] text-white"> 533 | Create Invoice 534 | </Button> 535 | </form> 536 | </CardContent> 537 | </Card> 538 | </div> 539 | </section> 540 | ); 541 | } 542 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/UpdateInvoiceForm.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | 5 | import { useRouter } from "next/navigation"; 6 | import Link from "next/link"; 7 | 8 | import { Controller, useForm } from "react-hook-form"; 9 | 10 | import { Button } from "@/components/ui/button"; 11 | import { Input } from "@/components/ui/input"; 12 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 13 | import { Label } from "@/components/ui/label"; 14 | 15 | import DatePicker from "@/components/dashboard/DatePicker"; 16 | 17 | import ProductCombobox from "./create/_components/ProductsCombobox"; 18 | import SizeCombobox from "./create/_components/SizeCombobox"; 19 | import StatusCombobox from "./_components/StatusCombobox"; 20 | 21 | import { getAllProducts } from "@/lib/actions/products/getAllProducts"; 22 | import { getAllSizePrice } from "@/lib/actions/size-price/getAll"; 23 | import { updateInvoice } from "@/lib/actions/invoice/updateInvoice"; 24 | 25 | import { calculateDiscountAmount, calculateDiscountPercent, getPageTitle } from "@/lib/utils"; 26 | import { ChevronRight, Trash2 } from "lucide-react"; 27 | 28 | import { toast } from "sonner"; 29 | 30 | export const metadata = { 31 | title: getPageTitle("Invoice Edit"), 32 | }; 33 | 34 | export default function UpdateInvoiceForm({ invoice }) { 35 | const router = useRouter(); 36 | 37 | const [products, setProducts] = useState([]); 38 | const [sizes, setSizes] = useState([]); 39 | 40 | const [invoiceDate, setInvoiceDate] = useState(invoice.invoiceDate?.split("T")[0] || ""); 41 | const [items, setItems] = useState([]); 42 | const [shippingPrice, setShippingPrice] = useState(invoice.shipping || 0); 43 | const [status, setStatus] = useState(invoice.status || "pending"); 44 | 45 | const [discountMode, setDiscountMode] = useState("amount"); 46 | const [discountInput, setDiscountInput] = useState(0); 47 | 48 | const { 49 | control, 50 | handleSubmit, 51 | formState: { errors }, 52 | } = useForm({ 53 | defaultValues: { 54 | invoiceNumber: invoice.invoiceNumber || "", 55 | buyerName: invoice.buyerName || "", 56 | }, 57 | mode: "onChange", 58 | }); 59 | 60 | useEffect(() => { 61 | const fetchData = async () => { 62 | const { data: productsData } = await getAllProducts(); 63 | const { data: sizeData } = await getAllSizePrice(); 64 | 65 | setProducts(productsData || []); 66 | setSizes(sizeData || []); 67 | }; 68 | 69 | fetchData(); 70 | }, []); 71 | 72 | useEffect(() => { 73 | if (invoice?.items?.length) { 74 | const mappedItems = invoice.items.map((item) => { 75 | const quantity = item.quantity || 0; 76 | const price = item.sizePrice?.price || 0; 77 | const subtotal = quantity * price; 78 | 79 | const discountAmount = item.discountAmount || 0; 80 | 81 | return { 82 | productId: item.productId, 83 | sizePriceId: item.sizePriceId, 84 | quantity, 85 | price, 86 | discountAmount, 87 | discountInput: String(discountAmount), 88 | discountMode: "amount", 89 | total: subtotal - discountAmount, 90 | }; 91 | }); 92 | 93 | setItems(mappedItems); 94 | } 95 | 96 | if (invoice?.discount !== undefined) { 97 | setDiscountInput(String(invoice.discount)); 98 | setDiscountMode("amount"); 99 | } 100 | }, [invoice]); 101 | 102 | const onUpdate = async (data) => { 103 | const isInvalid = items.some((item) => !item.productId || !item.sizePriceId); 104 | 105 | if (isInvalid) { 106 | toast.error("You must add product and size before submitting!"); 107 | return; 108 | } 109 | 110 | const result = await updateInvoice({ 111 | invoiceId: invoice.id, 112 | invoiceData: { 113 | invoiceNumber: data.invoiceNumber, 114 | buyerName: data.buyerName.trim().toLowerCase(), 115 | invoiceDate, 116 | totalPrice, 117 | discount: discountAmount, 118 | shipping: parseInt(shippingPrice), 119 | status, 120 | }, 121 | items, 122 | }); 123 | 124 | if (!result.success) { 125 | toast.error(result.error || "Failed to update invoice"); 126 | return; 127 | } 128 | 129 | toast.success("Invoice has been updated!"); 130 | router.push("/dashboard/invoices"); 131 | }; 132 | 133 | const handleItemChange = (index, field, value, mode = null) => { 134 | const updatedItems = [...items]; 135 | const item = updatedItems[index]; 136 | 137 | if (field === "sizePriceId") { 138 | const selectedSize = sizes.find((s) => s.id === value); 139 | item.sizePriceId = value; 140 | item.price = selectedSize?.price || 0; 141 | } else if (field === "quantity") { 142 | const parsed = parseInt(value, 10); 143 | item.quantity = isNaN(parsed) || parsed < 1 ? 1 : parsed; 144 | } else if (field === "price") { 145 | const parsed = parseInt(value, 10); 146 | item.price = isNaN(parsed) ? 0 : parsed; 147 | } else if (field === "discountMode") { 148 | item.discountMode = value; 149 | } else if (field === "discountInput") { 150 | item.discountInput = value; 151 | item.discountMode = mode; 152 | } else { 153 | item[field] = value; 154 | } 155 | 156 | const qty = item.quantity || 0; 157 | const price = item.price || 0; 158 | 159 | const rawTotal = qty * price; 160 | 161 | const discountAmount = calculateDiscountAmount({ 162 | quantity: item.quantity, 163 | price: item.price, 164 | discountInput: item.discountInput, 165 | discountMode: item.discountMode, 166 | }); 167 | 168 | item.discountAmount = discountAmount; 169 | item.total = rawTotal - discountAmount; 170 | 171 | setItems(updatedItems); 172 | }; 173 | 174 | const subtotal = items.reduce((sum, item) => sum + item.total, 0); 175 | 176 | const discountAmount = 177 | discountMode === "percent" 178 | ? Math.round(((parseFloat(discountInput) || 0) / 100) * subtotal) 179 | : parseInt(discountInput) || 0; 180 | 181 | const discountPercent = 182 | discountMode === "amount" 183 | ? ((parseInt(discountInput) || 0) / subtotal) * 100 184 | : parseFloat(discountInput) || 0; 185 | 186 | const totalPrice = subtotal + (parseInt(shippingPrice) || 0) - discountAmount; 187 | 188 | const addItem = () => { 189 | setItems([ 190 | ...items, 191 | { 192 | productId: "", 193 | sizePriceId: "", 194 | price: 0, 195 | quantity: 1, 196 | discountAmount: 0, 197 | discountInput: "0", 198 | discountMode: "amount", 199 | total: 0, 200 | }, 201 | ]); 202 | }; 203 | 204 | const removeItem = (index) => { 205 | const updated = [...items]; 206 | updated.splice(index, 1); 207 | setItems(updated); 208 | }; 209 | 210 | return ( 211 | <section className="w-full px-4 py-6 bg-[#fffaf0]"> 212 | <div className="bg-white rounded-xl shadow-md p-6 space-y-6 border border-[#f4e3d3]"> 213 | {/* Breadcrumbs (Mobile Only) */} 214 | <div className="block md:hidden text-sm text-gray-500 mb-4"> 215 | <nav className="flex items-center space-x-1"> 216 | <Link className="text-gray-400" href="/dashboard/invoices"> 217 | List Invoice 218 | </Link> 219 | <ChevronRight className="w-4 h-4 text-gray-400" /> 220 | <span className="text-gray-700 font-medium">Edit Invoice</span> 221 | </nav> 222 | </div> 223 | 224 | <Card className="border-0 shadow-none"> 225 | <CardHeader className="text-center mb-2"> 226 | <CardTitle className="font-bold text-3xl text-[#6D2315]">EDIT INVOICE</CardTitle> 227 | </CardHeader> 228 | 229 | <CardContent> 230 | <form onSubmit={handleSubmit(onUpdate)} className="space-y-8"> 231 | {/* Basic Info */} 232 | <div className="grid grid-cols-1 md:grid-cols-4 gap-4"> 233 | <div> 234 | <Label className="py-2 block text-sm text-gray-700">Invoice Number</Label> 235 | <Controller 236 | name="invoiceNumber" 237 | control={control} 238 | rules={{ 239 | required: "Invoice Number is required!", 240 | pattern: { 241 | value: /^\d{4}$/, 242 | message: "Invoice Number must be exactly 4 digits (0-9)", 243 | }, 244 | }} 245 | render={({ field }) => <Input {...field} maxLength={4} required />} 246 | /> 247 | {errors.invoiceNumber && ( 248 | <p role="alert" className="text-sm text-red-500"> 249 | {errors.invoiceNumber.message} 250 | </p> 251 | )} 252 | </div> 253 | <div> 254 | <Label className="py-2 block text-sm text-gray-700">Buyer Name</Label> 255 | <Controller 256 | name="buyerName" 257 | control={control} 258 | rules={{ 259 | required: "Buyer Name is required!", 260 | pattern: { 261 | value: /^[A-Za-z\s]+$/, 262 | message: "Buyer Name must contain only letters and spaces", 263 | }, 264 | }} 265 | render={({ field }) => <Input {...field} placeholder="Nama pembeli" required />} 266 | /> 267 | {errors.buyerName && ( 268 | <p role="alert" className="text-sm text-red-500"> 269 | {errors.buyerName.message} 270 | </p> 271 | )} 272 | </div> 273 | <div> 274 | <Label className="py-2 block text-sm text-gray-700">Invoice Date</Label> 275 | <DatePicker invoiceDate={invoiceDate} setInvoiceDate={setInvoiceDate} /> 276 | </div> 277 | <div className=""> 278 | <Label className="py-2 block text-sm text-gray-700">Status</Label> 279 | <StatusCombobox value={status} onChange={setStatus} required /> 280 | </div> 281 | </div> 282 | 283 | {/* Items */} 284 | <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> 285 | {items.map((item, index) => ( 286 | <div key={index}> 287 | <h3 className="text-sm font-medium text-[#6D2315] mb-1">Item {index + 1}</h3> 288 | 289 | <div className="bg-[#fffefb] border border-[#fceee4] rounded-md p-4 space-y-3"> 290 | {/* Item Select */} 291 | <div> 292 | <Label className="text-sm text-gray-700 mb-1 block">Item</Label> 293 | <ProductCombobox 294 | products={products} 295 | value={item.productId} 296 | onChange={(val) => handleItemChange(index, "productId", val)} 297 | /> 298 | </div> 299 | 300 | {/* Size & Qty */} 301 | <div className="grid grid-cols-1 md:grid-cols-2 gap-2"> 302 | <div> 303 | <Label className="text-sm text-gray-700 mb-1 block">Size</Label> 304 | <SizeCombobox 305 | sizes={sizes} 306 | value={item.sizePriceId} 307 | onChange={(val, price) => { 308 | handleItemChange(index, "sizePriceId", val); 309 | handleItemChange(index, "price", price); 310 | }} 311 | /> 312 | </div> 313 | <div> 314 | <Label className="text-sm text-gray-700 mb-1 block">Qty</Label> 315 | {/* desktop */} 316 | <div className="hidden md:block"> 317 | <Input 318 | type="number" 319 | value={item.quantity} 320 | onChange={(e) => handleItemChange(index, "quantity", e.target.value)} 321 | required 322 | /> 323 | </div> 324 | 325 | {/* mobile */} 326 | <div className="flex items-center gap-2 md:hidden"> 327 | <Input 328 | type="number" 329 | value={item.quantity} 330 | onChange={(e) => handleItemChange(index, "quantity", e.target.value)} 331 | className="w-15 text-center" 332 | /> 333 | <button 334 | type="button" 335 | className="w-15 px-2 py-1 border rounded bg-rose-500 text-white" 336 | onClick={() => 337 | handleItemChange( 338 | index, 339 | "quantity", 340 | Math.max(1, Number(item.quantity) - 1) 341 | ) 342 | } 343 | > 344 | - 345 | </button> 346 | <button 347 | type="button" 348 | className="w-15 px-2 py-1 border rounded bg-emerald-500 text-white" 349 | onClick={() => 350 | handleItemChange(index, "quantity", Number(item.quantity) + 1) 351 | } 352 | > 353 | + 354 | </button> 355 | </div> 356 | </div> 357 | </div> 358 | 359 | {/* Price & Total */} 360 | <div className="grid grid-cols-2 gap-2"> 361 | <div> 362 | <Label className="text-sm text-gray-700 mb-1 block">Price</Label> 363 | <Input 364 | type="number" 365 | value={item.price} 366 | disabled 367 | className="bg-gray-100" 368 | /> 369 | </div> 370 | <div> 371 | <Label className="text-sm text-gray-700 mb-1 block">Total</Label> 372 | <Input 373 | value={item.total.toLocaleString("id-ID")} 374 | disabled 375 | className="bg-gray-100" 376 | /> 377 | </div> 378 | </div> 379 | 380 | {/* Discount Each Item */} 381 | <div> 382 | <Label className="text-sm text-gray-700 mb-1 block"> 383 | Discount (Optional) 384 | </Label> 385 | <div className="grid grid-cols-2 gap-2"> 386 | <div> 387 | <Label className="text-xs text-gray-500">Percent (%)</Label> 388 | <Input 389 | type="number" 390 | placeholder="%" 391 | min={0} 392 | max={100} 393 | step="any" 394 | value={ 395 | item.discountMode === "percent" 396 | ? item.discountInput 397 | : calculateDiscountPercent(item) 398 | } 399 | onChange={(e) => 400 | handleItemChange(index, "discountInput", e.target.value, "percent") 401 | } 402 | /> 403 | </div> 404 | <div> 405 | <Label className="text-xs text-gray-500">Amount (Rp)</Label> 406 | <Input 407 | type="number" 408 | placeholder="Rp" 409 | min={0} 410 | value={ 411 | item.discountMode === "amount" 412 | ? item.discountInput 413 | : item.discountAmount 414 | } 415 | onChange={(e) => 416 | handleItemChange(index, "discountInput", e.target.value, "amount") 417 | } 418 | /> 419 | </div> 420 | </div> 421 | </div> 422 | 423 | {/* Delete button */} 424 | <div className="flex justify-end"> 425 | <Button 426 | type="button" 427 | variant="destructive" 428 | onClick={() => removeItem(index)} 429 | className="h-9 px-3" 430 | > 431 | <Trash2 className="w-4 h-4" /> 432 | </Button> 433 | </div> 434 | </div> 435 | </div> 436 | ))} 437 | 438 | {/* Add Item Button */} 439 | <div className="md:col-span-3"> 440 | <Button 441 | type="button" 442 | onClick={addItem} 443 | className="mt-2 bg-[#6D2315] hover:bg-[#591c10] text-white" 444 | > 445 | + Add Item 446 | </Button> 447 | </div> 448 | </div> 449 | 450 | {/* Shipping & Total */} 451 | <div className="grid grid-cols-1 md:grid-cols-12 gap-4"> 452 | {/* Discount General */} 453 | <div className="md:col-span-4"> 454 | <div className="bg-[#fffaf0] border border-[#f4e3d3] rounded-md px-4 py-3 h-full"> 455 | <Label className="block text-sm text-gray-700 mb-2">Discount (Optional)</Label> 456 | <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> 457 | <div> 458 | <Label className="text-xs text-gray-500 mb-1 block">Percent (%)</Label> 459 | <Input 460 | type="number" 461 | min={0} 462 | max={100} 463 | step="any" 464 | value={ 465 | discountMode === "percent" 466 | ? discountInput 467 | : discountPercent.toFixed(2) || 0 468 | } 469 | onChange={(e) => { 470 | setDiscountMode("percent"); 471 | setDiscountInput(e.target.value); 472 | }} 473 | /> 474 | </div> 475 | 476 | <div> 477 | <Label className="text-xs text-gray-500 mb-1 block">Amount (Rp)</Label> 478 | <Input 479 | type="number" 480 | min={0} 481 | value={discountMode === "amount" ? discountInput : discountAmount} 482 | onChange={(e) => { 483 | setDiscountMode("amount"); 484 | setDiscountInput(e.target.value); 485 | }} 486 | /> 487 | </div> 488 | </div> 489 | </div> 490 | </div> 491 | 492 | <div className="md:col-span-8 hidden md:block"></div> 493 | 494 | {/* Subtotal */} 495 | <div className="md:col-span-4"> 496 | <Label className="py-2 block text-sm text-gray-700">Subtotal</Label> 497 | <Input 498 | value={subtotal.toLocaleString("id-ID")} 499 | disabled 500 | className="bg-gray-100" 501 | /> 502 | </div> 503 | 504 | {/* Shipping */} 505 | <div className="md:col-span-4"> 506 | <Label className="py-2 block text-sm text-gray-700">Shipping Price</Label> 507 | <Input 508 | type="number" 509 | value={shippingPrice} 510 | onChange={(e) => setShippingPrice(e.target.value)} 511 | /> 512 | </div> 513 | 514 | {/* Total */} 515 | <div className="md:col-span-4"> 516 | <Label className="py-2 block text-sm text-gray-700">Total Price</Label> 517 | <Input 518 | value={totalPrice.toLocaleString("id-ID")} 519 | disabled 520 | className="bg-gray-100" 521 | /> 522 | </div> 523 | </div> 524 | 525 | <Button type="submit" className="w-full bg-[#6D2315] hover:bg-[#591c10] text-white"> 526 | Save 527 | </Button> 528 | </form> 529 | </CardContent> 530 | </Card> 531 | </div> 532 | </section> 533 | ); 534 | } 535 | ```