# 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 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.* .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/versions # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) .env* # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts /src/generated/prisma prisma db_backups ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown Tech stack: - Next.js - shadcn + tailwind - supabase + prisma Please visit [Cheese Stick Koe](https://cheesestick-koe.my.id). Thanks ♥️ ``` -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "paths": { "@/*": ["./src/*"] } } } ``` -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- ``` const config = { plugins: ["@tailwindcss/postcss"], }; export default config; ``` -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- ``` <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg> ``` -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- ``` /** @type {import('next').NextConfig} */ const nextConfig = { experimental: { authInterrupts: true, }, }; export default nextConfig; ``` -------------------------------------------------------------------------------- /src/lib/supabaseBrowser.js: -------------------------------------------------------------------------------- ```javascript import { createBrowserClient } from "@supabase/ssr"; export function supabaseBrowser() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ); } ``` -------------------------------------------------------------------------------- /src/lib/verifySession.js: -------------------------------------------------------------------------------- ```javascript import { supabaseServer } from "@/lib/supabaseServer"; export async function verifySession() { const supabase = await supabaseServer(); const { data: { session }, } = await supabase.auth.getSession(); return session; } ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/page.js: -------------------------------------------------------------------------------- ```javascript import { unauthorized } from "next/navigation"; import { verifySession } from "@/lib/verifySession"; import InvoicePage from "./InvoicePage"; export default async function InvoicesPage() { const session = await verifySession(); if (!session) { unauthorized(); } return <InvoicePage />; } ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/create/page.js: -------------------------------------------------------------------------------- ```javascript import { unauthorized } from "next/navigation"; import { verifySession } from "@/lib/verifySession"; import CreateInvoicePage from "./CreateInvoicePage"; export default async function Page() { const session = await verifySession(); if (!session) { unauthorized(); } return <CreateInvoicePage />; } ``` -------------------------------------------------------------------------------- /src/lib/actions/size-price/addSize.js: -------------------------------------------------------------------------------- ```javascript "use server"; import { supabaseServer } from "@/lib/supabaseServer"; export async function addSize({ size, price }) { const supabase = await supabaseServer(); const { data, error } = await supabase .from("ProductSizePrice") .insert([{ size, price }]) .select() .single(); return { data, error }; } ``` -------------------------------------------------------------------------------- /src/app/api/logout/route.js: -------------------------------------------------------------------------------- ```javascript import { cookies } from "next/headers"; import { NextResponse } from "next/server"; import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; export async function POST() { const supabase = createRouteHandlerClient({ cookies }); await supabase.auth.signOut(); return NextResponse.json({ message: "Logged out" }); } ``` -------------------------------------------------------------------------------- /src/lib/actions/products/addProduct.js: -------------------------------------------------------------------------------- ```javascript "use server"; import { supabaseServer } from "@/lib/supabaseServer"; export async function addProduct({ name, description }) { const supabase = await supabaseServer(); const { data, error } = await supabase .from("Product") .insert([{ name, description: description || null }]) .select() .single(); return { data, error }; } ``` -------------------------------------------------------------------------------- /src/app/dashboard/layout.jsx: -------------------------------------------------------------------------------- ```javascript import { Sidebar } from "@/components/dashboard/Sidebar"; export default function DashboardLayout({ children }) { return ( <div className="min-h-screen flex flex-col md:flex-row bg-[#fffaf0]"> <Sidebar /> <main className="flex-1 p-4 md:p-6 bg-white rounded-tl-xl md:rounded-tl-none shadow-inner"> {children} </main> </div> ); } ``` -------------------------------------------------------------------------------- /src/lib/actions/products/getAllProducts.js: -------------------------------------------------------------------------------- ```javascript "use server"; import { supabaseServer } from "@/lib/supabaseServer"; export async function getAllProducts(sortOrder = "asc") { const supabase = await supabaseServer(); const { data, error } = await supabase .from("Product") .select("id, name, description, createdAt") .order("name", { ascending: sortOrder === "asc" }); return { data, error }; } ``` -------------------------------------------------------------------------------- /src/lib/actions/size-price/getAll.js: -------------------------------------------------------------------------------- ```javascript "use server"; import { supabaseServer } from "@/lib/supabaseServer"; export async function getAllSizePrice(sortOrder = "asc") { const supabase = await supabaseServer(); const { data, error } = await supabase .from("ProductSizePrice") .select("id, size, price, createdAt") .order("size", { ascending: sortOrder === "asc" }); return { data, error }; } ``` -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- ``` import { dirname } from "path"; import { fileURLToPath } from "url"; import { FlatCompat } from "@eslint/eslintrc"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const compat = new FlatCompat({ baseDirectory: __dirname, }); const eslintConfig = [...compat.extends("next/core-web-vitals")]; export default eslintConfig; ``` -------------------------------------------------------------------------------- /src/app/dashboard/size-pricing/page.js: -------------------------------------------------------------------------------- ```javascript import { unauthorized } from "next/navigation"; import { getPageTitle } from "@/lib/utils"; import { verifySession } from "@/lib/verifySession"; import SizePage from "./SizePage"; export const metadata = { title: getPageTitle("Size"), }; export default function page() { const session = verifySession(); if (!session) { unauthorized(); } return <SizePage />; } ``` -------------------------------------------------------------------------------- /src/app/page.js: -------------------------------------------------------------------------------- ```javascript import { redirect } from "next/navigation"; import { supabaseServer } from "@/lib/supabaseServer"; import LoginForm from "./_components/LoginForm"; export default async function LoginPage() { const supabase = await supabaseServer(); const { data: { session }, } = await supabase.auth.getSession(); if (session) { redirect("/dashboard"); } return <LoginForm />; } ``` -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- ``` <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg> ``` -------------------------------------------------------------------------------- /src/app/dashboard/products/page.js: -------------------------------------------------------------------------------- ```javascript import { unauthorized } from "next/navigation"; import { getPageTitle } from "@/lib/utils"; import { verifySession } from "@/lib/verifySession"; import ProductPage from "./ProductPage"; export const metadata = { title: getPageTitle("Products"), }; export default function Page() { const session = verifySession(); if (!session) { unauthorized(); } return <ProductPage />; } ``` -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- ``` <svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg> ``` -------------------------------------------------------------------------------- /src/lib/actions/invoice/getAll.js: -------------------------------------------------------------------------------- ```javascript "use server"; import { supabaseServer } from "@/lib/supabaseServer"; export async function getAllInvoice(sortOrder = "asc") { const supabase = await supabaseServer(); const { data, error } = await supabase .from("Invoice") .select("id, invoiceNumber, buyerName, totalPrice, invoiceDate, status, createdAt") .order("invoiceNumber", { ascending: sortOrder === "asc" }); return { data, error }; } ``` -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- ```json { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": false, "tailwind": { "config": "", "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ``` -------------------------------------------------------------------------------- /src/app/not-found.js: -------------------------------------------------------------------------------- ```javascript export default function NotFound() { return ( <main className="grid min-h-screen place-items-center bg-white px-6 py-24 sm:py-32 lg:px-8"> <div className="text-center"> <p className="text-9xl font-semibold text-[#6d2315]">404</p> <h1 className="mt-4 text-2xl font-bold tracking-tight text-gray-500 sm:text-5xl"> Not Found </h1> <p className="mt-6 text-lg leading-8 text-gray-400">Could not find requested resource</p> </div> </main> ); } ``` -------------------------------------------------------------------------------- /src/lib/actions/invoice/getInvoiceWithItem.js: -------------------------------------------------------------------------------- ```javascript import { supabaseBrowser } from "@/lib/supabaseBrowser"; export async function getInvoiceWithItems(invoiceId) { const supabase = supabaseBrowser(); const { data: invoice, error: err1 } = await supabase .from("Invoice") .select("*") .eq("id", invoiceId) .single(); const { data: items, error: err2 } = await supabase .from("InvoiceItem") .select("*, product:Product(name), size:ProductSizePrice(size)") .eq("invoiceId", invoiceId); return { invoice, items }; } ``` -------------------------------------------------------------------------------- /src/components/ui/sonner.jsx: -------------------------------------------------------------------------------- ```javascript "use client" import { useTheme } from "next-themes" import { Toaster as Sonner } from "sonner"; const Toaster = ({ ...props }) => { const { theme = "system" } = useTheme() return ( <Sonner theme={theme} className="toaster group" style={ { "--normal-bg": "var(--popover)", "--normal-text": "var(--popover-foreground)", "--normal-border": "var(--border)" } } {...props} /> ); } export { Toaster } ``` -------------------------------------------------------------------------------- /src/app/unauthorized.js: -------------------------------------------------------------------------------- ```javascript export default function UnauthorizedPage() { return ( <main className="grid min-h-screen place-items-center bg-white px-6 py-24 sm:py-32 lg:px-8"> <div className="text-center"> <p className="text-9xl font-semibold text-[#6d2315]">401</p> <h1 className="mt-4 text-2xl font-bold tracking-tight text-gray-500 sm:text-5xl"> Unauthorized Access </h1> <p className="mt-6 text-lg leading-8 text-gray-400"> Sorry, you don't have permission to access this page. account. </p> </div> </main> ); } ``` -------------------------------------------------------------------------------- /src/app/dashboard/size-pricing/_components/AddSizeButton.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import AddSizeModal from "./AddSizeModal"; export default function AddSizeButton({ onSizeAdded }) { const [open, setOpen] = useState(false); return ( <> <Button onClick={() => setOpen(true)} className="bg-[#6D2315] hover:bg-[#591c10] text-white px-4 py-2 rounded-md" > Add Size </Button> <AddSizeModal open={open} setOpen={setOpen} onSuccess={() => { onSizeAdded?.(); }} /> </> ); } ``` -------------------------------------------------------------------------------- /src/lib/actions/invoice/getInvoiceByNumber.js: -------------------------------------------------------------------------------- ```javascript import { supabaseServer } from "@/lib/supabaseServer"; export const getInvoiceByNumber = async (invoiceNumber) => { const supabase = await supabaseServer(); const { data: invoice, error } = await supabase .from("Invoice") .select( ` *, items:InvoiceItem( id, productId, sizePriceId, quantity, discountAmount, product:Product(id, name), sizePrice:ProductSizePrice(id, price, size) ) ` ) .eq("invoiceNumber", invoiceNumber) .single(); if (error) return { error }; return { data: invoice }; }; ``` -------------------------------------------------------------------------------- /src/app/dashboard/products/_components/ProductModalButton.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import ProductModal from "./ProductModal"; export default function ProductModalButton({ onProductAdded }) { const [open, setOpen] = useState(false); return ( <> <Button onClick={() => setOpen(true)} className="bg-[#6D2315] hover:bg-[#591c10] text-white px-4 py-2 rounded-md" > Add Product </Button> <ProductModal open={open} setOpen={setOpen} onSuccess={() => { onProductAdded?.(); }} /> </> ); } ``` -------------------------------------------------------------------------------- /src/components/ui/label.jsx: -------------------------------------------------------------------------------- ```javascript "use client" import * as React from "react" import * as LabelPrimitive from "@radix-ui/react-label" import { cn } from "@/lib/utils" function Label({ className, ...props }) { return ( <LabelPrimitive.Root data-slot="label" className={cn( "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", className )} {...props} /> ); } export { Label } ``` -------------------------------------------------------------------------------- /src/app/dashboard/size-pricing/SizePage.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useRef } from "react"; import SizePriceTable from "./_components/Table"; import AddSizeButton from "./_components/AddSizeButton"; export default function SizePage() { const tableRef = useRef(); return ( <section className="p-4 space-y-4"> <div className="flex justify-between items-center mb-2"> <h1 className="text-2xl font-bold text-[#6D2315] tracking-tight">Size & Price List</h1> <AddSizeButton onSizeAdded={() => { tableRef.current.refetch(); }} /> </div> <SizePriceTable ref={tableRef} /> </section> ); } ``` -------------------------------------------------------------------------------- /src/lib/supabaseServer.js: -------------------------------------------------------------------------------- ```javascript import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; export const supabaseServer = async () => { const cookieStore = await cookies(); return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, { cookies: { getAll: async () => cookieStore.getAll(), setAll: async (cookiesToSet) => { cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options)); }, remove: async (name, options) => cookieStore.delete(name, options), }, } ); }; ``` -------------------------------------------------------------------------------- /src/lib/exportToPng.js: -------------------------------------------------------------------------------- ```javascript import html2canvas from "html2canvas-pro"; export async function exportInvoiceToPng(element, filename = "Invoice.png") { if (!element) return; const width = element.scrollWidth; const height = element.scrollHeight; const canvas = await html2canvas(element, { scale: 2, scrollX: 0, scrollY: 0, width, height, windowWidth: width, windowHeight: height, backgroundColor: "#ffffff", }); const link = document.createElement("a"); link.download = filename; link.href = canvas.toDataURL("image/png"); return new Promise((resolve) => { link.click(); resolve(); }); } ``` -------------------------------------------------------------------------------- /src/app/dashboard/products/ProductPage.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useRef } from "react"; import ProductModalButton from "./_components/ProductModalButton"; import ProductTable from "./_components/ProductTable"; export default function ProductPage() { const tableRef = useRef(); return ( <section className="p-4 space-y-4"> <div className="flex justify-between items-center mb-2"> <h1 className="text-2xl font-bold text-[#6D2315] tracking-tight">Product List</h1> <ProductModalButton onProductAdded={() => { tableRef.current?.refetch(); }} /> </div> <ProductTable ref={tableRef} /> </section> ); } ``` -------------------------------------------------------------------------------- /src/lib/actions/size-price/deleteSize.js: -------------------------------------------------------------------------------- ```javascript "use server"; import { supabaseServer } from "@/lib/supabaseServer"; import { revalidatePath } from "next/cache"; export async function deleteSize(sizeId) { const supabase = await supabaseServer(); try { const { error } = await supabase.from("ProductSizePrice").delete().match({ id: sizeId }); if (error) { console.error("❌ Supabase delete error:", error); return { success: false, message: "Failed to delete size" }; } revalidatePath("/dashboard/size-pricing"); return { success: true }; } catch (err) { console.log(err); return { success: false, message: "Failed to delete size" }; } } ``` -------------------------------------------------------------------------------- /src/lib/actions/invoice/deleteInvoice.js: -------------------------------------------------------------------------------- ```javascript "use server"; import { supabaseServer } from "@/lib/supabaseServer"; import { revalidatePath } from "next/cache"; export async function deleteInvoice(invoiceId) { const supabase = await supabaseServer(); try { const { error } = await supabase.from("Invoice").delete().match({ id: invoiceId }); if (error) { console.error("❌ Supabase delete error:", error); return { success: false, message: "Failed to delete invoice" }; } revalidatePath("/dashboard/invoices"); return { success: true }; } catch (err) { console.log(err); return { success: false, message: "Failed to delete invoice" }; } } ``` -------------------------------------------------------------------------------- /src/lib/actions/products/deleteProduct.js: -------------------------------------------------------------------------------- ```javascript "use server"; import { supabaseServer } from "@/lib/supabaseServer"; import { revalidatePath } from "next/cache"; export async function deleteProduct(productId) { const supabase = await supabaseServer(); try { const { error } = await supabase.from("Product").delete().match({ id: productId }); if (error) { console.error("❌ Supabase delete error:", error); return { success: false, message: "Failed to delete product" }; } revalidatePath("/dashboard/products"); return { success: true }; } catch (err) { console.log(err); return { success: false, message: "Failed to delete product" }; } } ``` -------------------------------------------------------------------------------- /src/lib/actions/size-price/updateSize.js: -------------------------------------------------------------------------------- ```javascript "use server"; import { supabaseServer } from "@/lib/supabaseServer"; import { revalidatePath } from "next/cache"; export async function updateSize(id, { size, price }) { const supabase = await supabaseServer(); try { const { error } = await supabase.from("ProductSizePrice").update({ size, price }).eq("id", id); if (error) { console.error("❌ Supabase delete error:", error); return { success: false, message: "Failed to update size" }; } revalidatePath("/dashboard/size-pricing"); return { success: true }; } catch (err) { console.log(err); return { success: false, message: "Failed to update size" }; } } ``` -------------------------------------------------------------------------------- /src/lib/actions/products/updateProduct.js: -------------------------------------------------------------------------------- ```javascript "use server"; import { supabaseServer } from "@/lib/supabaseServer"; import { revalidatePath } from "next/cache"; export async function updateProduct(id, { name, description }) { const supabase = await supabaseServer(); try { const { error } = await supabase.from("Product").update({ name, description }).eq("id", id); if (error) { console.error("❌ Supabase delete error:", error); return { success: false, message: "Failed to update product" }; } revalidatePath("/dashboard/products"); return { success: true }; } catch (err) { console.log(err); return { success: false, message: "Failed to update product" }; } } ``` -------------------------------------------------------------------------------- /src/app/layout.js: -------------------------------------------------------------------------------- ```javascript import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { Toaster } from "sonner"; const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"], }); const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"], }); export const metadata = { title: "Cheese Stick Koe", description: "Invoice generator for Cheese Stick Koe company", }; export default function RootLayout({ children }) { return ( <html lang="en"> <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}> {children} <Toaster position="top-center" richColors /> </body> </html> ); } ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/InvoicePage.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useRef } from "react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import InvoicesTable from "./Table"; export default function InvoicePage() { const tableRef = useRef(); return ( <section className="p-4 space-y-4"> <div className="flex justify-between items-center mb-2"> <h1 className="text-2xl font-bold text-[#6D2315] tracking-tight">Invoices List</h1> <Button asChild className="bg-[#6D2315] hover:bg-[#591c10] text-white px-4 py-2 rounded-md"> <Link href="/dashboard/invoices/create">Create Invoice</Link> </Button> </div> <InvoicesTable ref={tableRef} /> </section> ); } ``` -------------------------------------------------------------------------------- /middleware.js: -------------------------------------------------------------------------------- ```javascript import { createMiddlewareClient } from "@supabase/auth-helpers-nextjs"; import { NextResponse } from "next/server"; export async function middleware(req) { const res = NextResponse.next(); const supabase = createMiddlewareClient({ req, res }); const { data: { session }, } = await supabase.auth.getSession(); const { pathname } = req.nextUrl; if (session && pathname === "/") { return NextResponse.redirect(new URL("/dashboard", req.url)); } // trying to access protected route if (!session && pathname.startsWith("/dashboard")) { return NextResponse.redirect(new URL("/", req.url)); } return res; } export const config = { matcher: ["/", "/dashboard/:path*"], }; ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/[invoiceNumber]/page.jsx: -------------------------------------------------------------------------------- ```javascript import { unauthorized } from "next/navigation"; import { verifySession } from "@/lib/verifySession"; import UpdateInvoiceForm from "../UpdateInvoiceForm"; import { getInvoiceByNumber } from "@/lib/actions/invoice/getInvoiceByNumber"; export default async function UpdateInvoicePage(props) { const session = await verifySession(); if (!session) { unauthorized(); } const { invoiceNumber } = await props.params; if (!invoiceNumber) return <div className="text-red-500">Invoice number not found</div>; const { data: invoice, error } = await getInvoiceByNumber(invoiceNumber); if (error || !invoice) { return <div className="text-red-500">Invoice not found</div>; } return <UpdateInvoiceForm invoice={invoice} />; } ``` -------------------------------------------------------------------------------- /src/components/ui/input.jsx: -------------------------------------------------------------------------------- ```javascript import * as React from "react" import { cn } from "@/lib/utils" function Input({ className, type, ...props }) { return ( <input type={type} data-slot="input" className={cn( "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className )} {...props} /> ); } export { Input } ``` -------------------------------------------------------------------------------- /src/app/dashboard/size-pricing/_components/DeleteSizeModal.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { toast } from "sonner"; import { DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import Modal from "@/components/dashboard/Modal"; import { deleteSize } from "@/lib/actions/size-price/deleteSize"; export default function DeleteSizeModal({ open, onOpenChange, sizeId, onSuccess }) { const handleDelete = async () => { const result = await deleteSize(sizeId); if (result?.success) { toast.success("Size has been deleted"); onSuccess?.(); onOpenChange(false); } else { toast.error(result?.message || "Failed to delete size"); } }; return ( <Modal open={open} onOpenChange={onOpenChange} title="Delete Size" color="red" submitLabel="Delete" showCancel={false} > <p>Are you sure want to delete this size?</p> <DialogFooter> <Button variant="destructive" onClick={handleDelete} className={"bg-rose-600 hover:bg-red-600 w-24"} > Delete </Button> </DialogFooter> </Modal> ); } ``` -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- ``` <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg> ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/_components/DeleteInvoiceModal.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import Modal from "@/components/dashboard/Modal"; import { deleteInvoice } from "@/lib/actions/invoice/deleteInvoice"; import { toast } from "sonner"; export default function DeleteInvoiceModal({ open, onOpenChange, invoiceId, onSuccess }) { const handleDelete = async () => { if (!invoiceId) { toast.error("Invoice ID not found"); return; } const result = await deleteInvoice(invoiceId); if (result?.success) { toast.success("Invoice has been deleted"); onSuccess?.(); onOpenChange(false); } else { toast.error(result?.message || "Failed to delete invoice"); } }; return ( <Modal open={open} onOpenChange={onOpenChange} title="Delete Invoice" color="red" submitLabel="Delete" showCancel={false} > <p>Are you sure want to delete this invoice?</p> <DialogFooter> <Button variant="destructive" onClick={handleDelete} className={"bg-rose-600 hover:bg-red-600 w-24"} > Delete </Button> </DialogFooter> </Modal> ); } ``` -------------------------------------------------------------------------------- /src/app/dashboard/products/_components/ProductModal.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useState } from "react"; import Modal from "@/components/dashboard/Modal"; import { addProduct } from "@/lib/actions/products/addProduct"; import { toast } from "sonner"; export default function ProductModal({ open, setOpen, onSuccess }) { const [name, setName] = useState(""); const [description, setDescription] = useState(""); const handleSubmit = async (e) => { e.preventDefault(); const { data, error } = await addProduct({ name, description }); if (error && error.message) { toast.error("Failed to add product: " + error.message); } else { toast.success("Product has been added"); onSuccess?.(data); setOpen(false); setName(""); setDescription(""); } }; return ( <Modal open={open} onOpenChange={setOpen} title="Add Product" color="default" fields={[ { label: "Product Name", value: name, onChange: (e) => setName(e.target.value), required: true, }, { label: "Description", value: description, onChange: (e) => setDescription(e.target.value), }, ]} onSubmit={handleSubmit} submitLabel="Add" showCancel={false} buttonStyling="bg-primary" /> ); } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "invoice-generator", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev --turbopack", "build": "next build", "start": "next start", "lint": "next lint", "seed": "node prisma/seed.js" }, "dependencies": { "@prisma/client": "^6.11.1", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-slot": "^1.2.3", "@supabase/auth-helpers-nextjs": "^0.10.0", "@supabase/ssr": "^0.6.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "dotenv": "^17.2.0", "html2canvas-pro": "^1.5.11", "lucide-react": "^0.525.0", "next": "15.4.1", "next-themes": "^0.4.6", "prisma": "^6.11.1", "react": "19.1.0", "react-day-picker": "^9.8.0", "react-dom": "19.1.0", "react-hook-form": "^7.62.0", "sonner": "^2.0.6", "tailwind-merge": "^3.3.1", "vaul": "^1.1.2" }, "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "autoprefixer": "^10.4.21", "eslint": "^9", "eslint-config-next": "15.4.1", "postcss": "^8.5.6", "tailwindcss": "^4.1.11", "tw-animate-css": "^1.3.5" } } ``` -------------------------------------------------------------------------------- /src/app/dashboard/size-pricing/_components/AddSizeModal.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useState } from "react"; import Modal from "@/components/dashboard/Modal"; import { addSize } from "@/lib/actions/size-price/addSize"; import { toast } from "sonner"; export default function AddSizeModal({ open, setOpen, onSuccess }) { const [size, setSize] = useState(""); const [price, setPrice] = useState(""); const handleSubmit = async (e) => { e.preventDefault(); const { data, error } = await addSize({ size, price }); if (error && error.message) { toast.error("Failed to add size: " + error.message); } else { toast.success("Size has been added"); onSuccess?.(data); setOpen(false); setSize(""); setPrice(""); } }; return ( <Modal open={open} onOpenChange={setOpen} title="Add Size" color="default" fields={[ { label: "Size name", value: size, onChange: (e) => setSize(e.target.value), required: true, }, { type: "number", label: "Price", value: price === "" ? "" : Number(price), onChange: (e) => { const val = e.target.value; setPrice(val === "" ? "" : parseInt(val)); }, required: true, }, ]} onSubmit={handleSubmit} submitLabel="Add" showCancel={false} buttonStyling="bg-primary" /> ); } ``` -------------------------------------------------------------------------------- /src/components/ui/radio-group.jsx: -------------------------------------------------------------------------------- ```javascript "use client" import * as React from "react" import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" import { CircleIcon } from "lucide-react" import { cn } from "@/lib/utils" function RadioGroup({ className, ...props }) { return ( <RadioGroupPrimitive.Root data-slot="radio-group" className={cn("grid gap-3", className)} {...props} /> ); } function RadioGroupItem({ className, ...props }) { return ( <RadioGroupPrimitive.Item data-slot="radio-group-item" className={cn( "border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", className )} {...props}> <RadioGroupPrimitive.Indicator data-slot="radio-group-indicator" className="relative flex items-center justify-center"> <CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" /> </RadioGroupPrimitive.Indicator> </RadioGroupPrimitive.Item> ); } export { RadioGroup, RadioGroupItem } ``` -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- ``` <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg> ``` -------------------------------------------------------------------------------- /src/components/ui/popover.jsx: -------------------------------------------------------------------------------- ```javascript "use client" import * as React from "react" import * as PopoverPrimitive from "@radix-ui/react-popover" import { cn } from "@/lib/utils" function Popover({ ...props }) { return <PopoverPrimitive.Root data-slot="popover" {...props} />; } function PopoverTrigger({ ...props }) { return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />; } function PopoverContent({ className, align = "center", sideOffset = 4, ...props }) { return ( <PopoverPrimitive.Portal> <PopoverPrimitive.Content data-slot="popover-content" align={align} sideOffset={sideOffset} className={cn( "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", className )} {...props} /> </PopoverPrimitive.Portal> ); } function PopoverAnchor({ ...props }) { return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />; } export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } ``` -------------------------------------------------------------------------------- /src/components/ui/badge.jsx: -------------------------------------------------------------------------------- ```javascript import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva } from "class-variance-authority"; import { cn } from "@/lib/utils" const badgeVariants = cva( "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", { variants: { variant: { default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", destructive: "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", }, }, defaultVariants: { variant: "default", }, } ) function Badge({ className, variant, asChild = false, ...props }) { const Comp = asChild ? Slot : "span" return ( <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} /> ); } export { Badge, badgeVariants } ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/create/_components/SizeCombobox.jsx: -------------------------------------------------------------------------------- ```javascript import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Check, ChevronsUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; export default function SizeCombobox({ sizes, value, onChange }) { const [open, setOpen] = useState(false); return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <Button variant="outline" role="combobox" aria-expanded={open} className="w-full justify-between" > {value ? sizes.find((s) => s.id === value)?.size : "Choose size..."} <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> <PopoverContent className="w-full p-0"> <Command> <CommandInput placeholder="Search size..." /> <CommandList> <CommandEmpty>Size not found</CommandEmpty> <CommandGroup> {sizes.map((s) => ( <CommandItem key={s.id} value={s.size} onSelect={() => { onChange(s.id, s.price); setOpen(false); }} > {s.size} <Check className={cn("ml-auto h-4 w-4", value === s.id ? "opacity-100" : "opacity-0")} /> </CommandItem> ))} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> ); } ``` -------------------------------------------------------------------------------- /src/app/dashboard/size-pricing/_components/EditSizeModal.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useState, useEffect } from "react"; import { DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import Modal from "@/components/dashboard/Modal"; import { updateSize } from "@/lib/actions/size-price/updateSize"; import { toast } from "sonner"; export default function EditSizeModal({ open, onOpenChange, data, onSuccess }) { const [size, setSize] = useState(""); const [price, setPrice] = useState(""); useEffect(() => { if (data) { setSize(data.size || ""); setPrice(data.price || ""); } }, [data]); const handleUpdate = async () => { const result = await updateSize(data.id, { size, price }); if (result?.success) { toast.success("Product has been updated"); onSuccess?.({ ...data, size, price }); onOpenChange(false); } else { toast.error(result?.message || "Failed to update size"); } }; return ( <Modal open={open} onOpenChange={onOpenChange} title="Edit Size" color="blue" submitLabel="Update" showCancel={false} > <div className="space-y-2"> <Input placeholder="Size name" value={size} onChange={(e) => setSize(e.target.value)} /> <Input placeholder="Price" type="number" value={price} onChange={(e) => { const val = e.target.value; setPrice(val === "" ? "" : parseInt(val)); }} /> </div> <DialogFooter className="pt-4"> <Button onClick={handleUpdate} className="bg-sky-500 hover:bg-blue-500 w-full"> Update </Button> </DialogFooter> </Modal> ); } ``` -------------------------------------------------------------------------------- /src/app/dashboard/products/_components/ProductEditModal.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useState, useEffect } from "react"; import { DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import Modal from "@/components/dashboard/Modal"; import { updateProduct } from "@/lib/actions/products/updateProduct"; import { toast } from "sonner"; export default function ProductEditModal({ open, onOpenChange, product, onSuccess }) { const [name, setName] = useState(""); const [description, setDescription] = useState(""); useEffect(() => { if (product) { setName(product.name || ""); setDescription(product.description || ""); } }, [product]); const handleUpdate = async () => { const result = await updateProduct(product.id, { name, description }); if (result?.success) { toast.success("Product has been updated"); onSuccess?.({ ...product, name, description }); onOpenChange(false); } else { toast.error(result?.message || "Failed to update product"); } }; return ( <Modal open={open} onOpenChange={onOpenChange} title="Edit Product" color="blue" submitLabel="Update" showCancel={false} > <div className="space-y-2"> <Input placeholder="Product name" value={name} onChange={(e) => setName(e.target.value)} /> <Input placeholder="Description" value={description} onChange={(e) => setDescription(e.target.value)} /> </div> <DialogFooter className="pt-4"> <Button onClick={handleUpdate} className="bg-sky-500 hover:bg-blue-500 w-full"> Update </Button> </DialogFooter> </Modal> ); } ``` -------------------------------------------------------------------------------- /src/app/dashboard/products/_components/ProductDeleteModal.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import Modal from "@/components/dashboard/Modal"; import { deleteProduct } from "@/lib/actions/products/deleteProduct"; import { toast } from "sonner"; export default function ProductDeleteModal({ open, onOpenChange, productId, onSuccess }) { const handleDelete = async () => { const result = await deleteProduct(productId); if (result?.success) { toast.success("Product has been deleted"); onSuccess?.(); onOpenChange(false); } else { toast.error(result?.message || "Failed to delete product"); } }; return ( <Modal open={open} onOpenChange={onOpenChange} title="Delete Product" color="red" submitLabel="Delete" showCancel={false} > <p>Are you sure want to delete this product?</p> <DialogFooter> <Button variant="destructive" onClick={handleDelete} className={"bg-rose-600 hover:bg-red-600 w-24"} > Delete </Button> </DialogFooter> </Modal> // <Dialog open={open} onOpenChange={onOpenChange}> // <DialogContent> // <DialogHeader> // <DialogTitle className={"text-red-500"}>Delete Product</DialogTitle> // </DialogHeader> // <p>Are you sure want to delete this product?</p> // <DialogFooter> // <Button variant="outline" onClick={() => onOpenChange(false)}> // Cancel // </Button> // <Button // variant="destructive" // onClick={handleDelete} // className={"bg-rose-600 hover:bg-red-600"} // > // Delete // </Button> // </DialogFooter> // </DialogContent> // </Dialog> ); } ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/create/_components/ProductsCombobox.jsx: -------------------------------------------------------------------------------- ```javascript import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Check, ChevronsUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; export default function ProductCombobox({ products, value, onChange }) { const [open, setOpen] = useState(false); return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <Button variant="outline" role="combobox" aria-expanded={open} className="w-full justify-between" > {value ? products.find((p) => p.id === value)?.name : "Choose item..."} <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> <PopoverContent className="w-full p-0"> <Command> <CommandInput placeholder="Search item..." /> <CommandList> <CommandEmpty>Item not found</CommandEmpty> <CommandGroup> {products.map((product) => ( <CommandItem key={product.id} value={product.name} onSelect={(currentValue) => { const selected = products.find((p) => p.name === currentValue); if (selected) { onChange(selected.id); setOpen(false); } }} > {product.name} <Check className={cn( "ml-auto h-4 w-4", value === product.id ? "opacity-100" : "opacity-0" )} /> </CommandItem> ))} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> ); } ``` -------------------------------------------------------------------------------- /src/lib/actions/invoice/updateInvoice.js: -------------------------------------------------------------------------------- ```javascript "use server"; import { supabaseServer } from "@/lib/supabaseServer"; export async function updateInvoice({ invoiceId, invoiceData, items }) { const supabase = await supabaseServer(); try { // is invoice number already exist? const { data: existing, error: checkError } = await supabase .from("Invoice") .select("id") .eq("invoiceNumber", invoiceData.invoiceNumber) .neq("id", invoiceId) // exclude current invoice number .maybeSingle(); if (checkError) throw checkError; if (existing) return { error: "Invoice number already existed!" }; const { error: invoiceError } = await supabase .from("Invoice") .update({ invoiceNumber: invoiceData.invoiceNumber, buyerName: invoiceData.buyerName, invoiceDate: invoiceData.invoiceDate, totalPrice: invoiceData.totalPrice, discount: invoiceData.discount, shipping: parseInt(invoiceData.shipping) || 0, status: invoiceData.status, }) .eq("id", invoiceId); if (invoiceError) { throw invoiceError; } const { error: deleteError } = await supabase .from("InvoiceItem") .delete() .eq("invoiceId", invoiceId); if (deleteError) { throw deleteError; } // insert new items const itemsToInsert = items.map((item) => ({ invoiceId, productId: item.productId, sizePriceId: item.sizePriceId, quantity: item.quantity, subtotal: item.quantity * (item.price || 0) - (item.discountAmount || 0), discountAmount: item.discountAmount || 0, })); const { error: insertError } = await supabase.from("InvoiceItem").insert(itemsToInsert); if (insertError) { throw insertError; } return { success: true }; } catch (err) { console.error("Error:", err); return { success: false, error: err.message }; } } ``` -------------------------------------------------------------------------------- /src/components/ui/card.jsx: -------------------------------------------------------------------------------- ```javascript import * as React from "react" import { cn } from "@/lib/utils" function Card({ className, ...props }) { return ( <div data-slot="card" className={cn( "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className )} {...props} /> ); } function CardHeader({ className, ...props }) { return ( <div data-slot="card-header" className={cn( "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", className )} {...props} /> ); } function CardTitle({ className, ...props }) { return ( <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} /> ); } function CardDescription({ className, ...props }) { return ( <div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props} /> ); } function CardAction({ className, ...props }) { return ( <div data-slot="card-action" className={cn( "col-start-2 row-span-2 row-start-1 self-start justify-self-end", className )} {...props} /> ); } function CardContent({ className, ...props }) { return (<div data-slot="card-content" className={cn("px-6", className)} {...props} />); } function CardFooter({ className, ...props }) { return ( <div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} /> ); } export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, } ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/_components/StatusCombobox.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Check, ChevronsUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; const statuses = [ { label: "Pending", value: "pending" }, { label: "Success", value: "success" }, { label: "Canceled", value: "canceled" }, ]; export default function StatusCombobox({ value, onChange }) { const [open, setOpen] = useState(false); const selected = statuses.find((s) => s.value === value); return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <Button variant="outline" role="combobox" aria-expanded={open} className="w-full justify-between" > {selected ? selected.label : "Choose status..."} <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> <PopoverContent className="w-full p-0"> <Command> <CommandInput placeholder="Search status..." /> <CommandList> <CommandEmpty>Status not found</CommandEmpty> <CommandGroup> {statuses.map((s) => ( <CommandItem key={s.value} value={s.value} onSelect={(currentVal) => { onChange(currentVal); setOpen(false); }} > {s.label} <Check className={cn( "ml-auto h-4 w-4", value === s.value ? "opacity-100" : "opacity-0" )} /> </CommandItem> ))} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> ); } ``` -------------------------------------------------------------------------------- /src/components/dashboard/Modal.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; export default function Modal({ open, onOpenChange, title, color = "default", // 'default' | 'red' | 'blue' | 'green' (styling title/button) fields = [], // [{ label, placeholder, value, onChange, required }] onSubmit, submitLabel = "Submit", footerButtons, // override footer children, // optional custom content buttonStyling, }) { const getColorClass = () => { switch (color) { case "red": return "text-red-500"; case "blue": return "text-blue-500"; case "green": return "text-green-500"; default: return ""; } }; return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent> <DialogHeader> <DialogTitle className={getColorClass()}>{title}</DialogTitle> </DialogHeader> {/* CASE 1: children custom (non-form) */} {children && children} {!children && fields.length > 0 && ( <form onSubmit={onSubmit} className="space-y-4 mt-4"> {fields.map((field) => ( <div className="space-y-1" key={field.label || field.placeholder || field.value}> {field.label && <Label>{field.label}</Label>} <Input type={field.type || "text"} placeholder={field.placeholder} value={field.value} onChange={field.onChange} required={field.required} /> </div> ))} <DialogFooter> <Button type="submit" className={`w-full ${buttonStyling}`}> {submitLabel} </Button> </DialogFooter> </form> )} {footerButtons && <DialogFooter>{footerButtons}</DialogFooter>} </DialogContent> </Dialog> ); } ``` -------------------------------------------------------------------------------- /src/components/ui/button.jsx: -------------------------------------------------------------------------------- ```javascript import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva } from "class-variance-authority"; import { cn } from "@/lib/utils" const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9", }, }, defaultVariants: { variant: "default", size: "default", }, } ) function Button({ className, variant, size, asChild = false, ...props }) { const Comp = asChild ? Slot : "button" return ( <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} /> ); } export { Button, buttonVariants } ``` -------------------------------------------------------------------------------- /src/lib/actions/invoice/submitInvoice.js: -------------------------------------------------------------------------------- ```javascript "use server"; import { supabaseServer } from "@/lib/supabaseServer"; export const submitInvoice = async ({ invoiceNumber, buyerName, invoiceDate, shippingPrice, discountAmount = 0, totalPrice, items, user, }) => { if (!user) { return { error: "User is not login!" }; } if (!invoiceNumber.trim() || !buyerName.trim() || items.length === 0) { return { error: "Invoice number, buyer name, and at least one item are required!" }; } const supabase = await supabaseServer(); // validate number inputs const shipping = Number.isNaN(parseInt(shippingPrice)) ? 0 : parseInt(shippingPrice); const discount = Number.isNaN(parseInt(discountAmount)) ? 0 : parseInt(discountAmount); const total = Number.isNaN(parseInt(totalPrice)) ? 0 : parseInt(totalPrice); try { // is invoice number already exist? const { data: existing, error: checkError } = await supabase .from("Invoice") .select("id") .eq("invoiceNumber", invoiceNumber) .maybeSingle(); if (checkError) throw checkError; if (existing) return { error: "Invoice number already existed!" }; // insert invoice const { data: invoice, error: invoiceError } = await supabase .from("Invoice") .insert([ { invoiceNumber, buyerName, invoiceDate: new Date(invoiceDate), shipping, discount, totalPrice: total, status: "pending", userId: user.id, }, ]) .select() .single(); if (invoiceError || !invoice) throw invoiceError || new Error("Failed to insert invoice!"); // insert invoice items const invoiceItems = items.map((item) => ({ invoiceId: invoice.id, productId: item.productId, sizePriceId: item.sizePriceId, quantity: item.quantity, subtotal: item.total, discountAmount: item.discountAmount || 0, })); const { error: itemError } = await supabase.from("InvoiceItem").insert(invoiceItems); if (itemError) throw itemError; return { success: true, message: "Invoice has been created", invoice }; } catch (err) { return { error: "Something went wrong while saving invoice!" }; } }; ``` -------------------------------------------------------------------------------- /src/components/dashboard/DatePicker.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useState } from "react"; import { CalendarIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; function formatDate(date) { if (!date) { return ""; } return date.toLocaleDateString("id-ID", { day: "2-digit", month: "long", year: "numeric", }); } function isValidDate(date) { if (!date) { return false; } return !isNaN(date.getTime()); } export default function DatePicker({ invoiceDate, setInvoiceDate }) { const [open, setOpen] = useState(false); const [selectedDate, setSelectedDate] = useState( invoiceDate ? new Date(invoiceDate) : new Date() ); return ( <div className="flex flex-col gap-3"> <div className="relative flex gap-2"> <Input id="date" value={formatDate(new Date(invoiceDate))} placeholder="June 01, 2025" className="bg-background pr-10" onChange={(e) => { const date = new Date(e.target.value); if (!isNaN(date)) { date.setHours(12, 0, 0, 0); setSelectedDate(date); setInvoiceDate(date.toISOString()); } }} onKeyDown={(e) => { if (e.key === "ArrowDown") { e.preventDefault(); setOpen(true); } }} /> <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <Button id="date-picker" variant="ghost" className="absolute top-1/2 right-2 size-6 -translate-y-1/2" > <CalendarIcon className="size-3.5" /> <span className="sr-only">Select date</span> </Button> </PopoverTrigger> <PopoverContent className="w-auto overflow-hidden p-0" align="end" alignOffset={-8} sideOffset={10} > <Calendar mode="single" selected={selectedDate} captionLayout="dropdown" month={selectedDate} onMonthChange={(month) => setSelectedDate(month)} onSelect={(date) => { if (date) { setSelectedDate(date); const withNoon = new Date(date); withNoon.setHours(12, 0, 0, 0); setInvoiceDate(withNoon.toISOString()); setOpen(false); } }} /> </PopoverContent> </Popover> </div> </div> ); } ``` -------------------------------------------------------------------------------- /src/lib/utils.js: -------------------------------------------------------------------------------- ```javascript import { clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs) { return twMerge(clsx(inputs)); } export function getStatusVariant(status) { switch (status) { case "success": return "bg-green-100 text-green-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-green-900 dark:text-green-300"; case "pending": return "bg-gray-100 text-gray-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-gray-700 dark:text-gray-300"; case "canceled": return "bg-red-100 text-red-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-red-900 dark:text-red-300"; default: return "bg-purple-100 text-purple-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-purple-900 dark:text-purple-300"; } } export function toTitleCase(str) { return str.replace( /\w\S*/g, (text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase() ); } export function formatInvoiceDateTime(dateStr, timeStr) { const date = new Date(dateStr); const time = new Date(timeStr); const day = date.getDate().toString().padStart(2, "0"); const month = (date.getMonth() + 1).toString().padStart(2, "0"); const year = date.getFullYear(); const hours = time.getHours().toString().padStart(2, "0"); const minutes = time.getMinutes().toString().padStart(2, "0"); return `${day}/${month}/${year} ${hours}:${minutes}`; } export function formatDateFilename(date) { if (!date) return ""; const dates = new Date(date); const day = String(dates.getDate()).padStart(2, "0"); const month = String(dates.getMonth() + 1).padStart(2, "0"); const year = dates.getFullYear(); return `${day}/${month}/${year}`; } export function getPageTitle(subTitle) { return `Cheese Stick Koe - ${subTitle}`; } export function calculateDiscountPercent({ quantity, price, discountMode, discountInput, discountAmount, }) { const rawTotal = (quantity || 0) * (price || 0); if (!rawTotal) return "0"; const amount = discountMode === "percent" ? (parseFloat(discountInput) / 100) * rawTotal : parseInt(discountInput) || discountAmount || 0; const percent = (amount / rawTotal) * 100; return isNaN(percent) ? "0" : percent.toFixed(2); } export function calculateDiscountAmount({ quantity, price, discountInput, discountMode }) { const rawTotal = (quantity || 0) * (price || 0); if (discountMode === "percent") { return Math.round(((parseFloat(discountInput) || 0) / 100) * rawTotal); } return parseInt(discountInput) || 0; } ``` -------------------------------------------------------------------------------- /src/components/dashboard/Sidebar.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useState } from "react"; import { Menu, X } from "lucide-react"; const navItems = [ { name: "Dashboard", href: "/dashboard" }, { name: "Products", href: "/dashboard/products" }, { name: "Size & Price", href: "/dashboard/size-pricing" }, { name: "Invoices", href: "/dashboard/invoices", children: [ { name: "List Invoices", href: "/dashboard/invoices" }, { name: "Create Invoice", href: "/dashboard/invoices/create" }, ], }, ]; export function Sidebar() { const pathname = usePathname(); const router = useRouter(); const [open, setOpen] = useState(false); const handleLogout = async () => { try { const res = await fetch("/api/logout", { method: "POST", }); if (res.ok) { router.push("/"); } else { console.error("Logout failed"); } } catch (err) { console.error("Logout error:", err); } }; return ( <> {/* Burger Icon */} <button onClick={() => setOpen(!open)} className="md:hidden p-4 focus:outline-none"> {open ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />} </button> {/* Sidebar */} <aside className={`${ open ? "block" : "hidden" } md:block w-full md:w-64 bg-[#fff7f3] border-r border-[#f1e3db] shadow-sm fixed md:static z-50 top-0 left-0 h-full md:h-auto`} > {/* Header */} <div className="p-4 border-b border-[#f1e3db] flex justify-between items-center bg-[#fff2ea]"> <h1 className="text-lg font-bold text-[#6D2315] tracking-wide">Invoice App</h1> <button onClick={() => setOpen(false)} className="md:hidden"> <X className="w-5 h-5 text-[#6D2315]" /> </button> </div> {/* Nav Items */} <nav className="flex flex-col p-4 space-y-2 text-sm text-gray-700"> {navItems.map((item) => { const isActive = pathname === item.href || pathname.startsWith(item.href + "/"); if (item.children) { return ( <div key={item.href} className="space-y-1"> <div className={`px-3 py-2 rounded-md font-semibold ${ isActive ? "bg-[#f9e0cd] text-[#6D2315]" : "text-gray-700 hover:bg-[#fceee4]" }`} > {item.name} </div> <div className="ml-4 space-y-1"> {item.children.map((child) => ( <Link key={child.href} href={child.href} className={`block px-3 py-2 rounded-md text-sm ${ pathname === child.href ? "bg-[#f9e0cd] text-[#6D2315] font-semibold" : "hover:bg-[#fceee4] text-gray-700" }`} onClick={() => setOpen(false)} > {child.name} </Link> ))} </div> </div> ); } return ( <Link key={item.href} href={item.href} className={`px-3 py-2 rounded-md ${ pathname === item.href ? "bg-[#f9e0cd] text-[#6D2315] font-semibold" : "hover:bg-[#fceee4] text-gray-700" }`} onClick={() => setOpen(false)} > {item.name} </Link> ); })} <button onClick={handleLogout} className="mt-4 text-sm text-left text-red-500 hover:bg-red-100 px-3 py-2 rounded-md" > Logout </button> </nav> </aside> </> ); } ``` -------------------------------------------------------------------------------- /src/components/ui/dialog.jsx: -------------------------------------------------------------------------------- ```javascript "use client" import * as React from "react" import * as DialogPrimitive from "@radix-ui/react-dialog" import { XIcon } from "lucide-react" import { cn } from "@/lib/utils" function Dialog({ ...props }) { return <DialogPrimitive.Root data-slot="dialog" {...props} />; } function DialogTrigger({ ...props }) { return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />; } function DialogPortal({ ...props }) { return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />; } function DialogClose({ ...props }) { return <DialogPrimitive.Close data-slot="dialog-close" {...props} />; } function DialogOverlay({ className, ...props }) { return ( <DialogPrimitive.Overlay data-slot="dialog-overlay" className={cn( "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", className )} {...props} /> ); } function DialogContent({ className, children, showCloseButton = true, ...props }) { return ( <DialogPortal data-slot="dialog-portal"> <DialogOverlay /> <DialogPrimitive.Content data-slot="dialog-content" className={cn( "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", className )} {...props}> {children} {showCloseButton && ( <DialogPrimitive.Close data-slot="dialog-close" className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"> <XIcon /> <span className="sr-only">Close</span> </DialogPrimitive.Close> )} </DialogPrimitive.Content> </DialogPortal> ); } function DialogHeader({ className, ...props }) { return ( <div data-slot="dialog-header" className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props} /> ); } function DialogFooter({ className, ...props }) { return ( <div data-slot="dialog-footer" className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} /> ); } function DialogTitle({ className, ...props }) { return ( <DialogPrimitive.Title data-slot="dialog-title" className={cn("text-lg leading-none font-semibold", className)} {...props} /> ); } function DialogDescription({ className, ...props }) { return ( <DialogPrimitive.Description data-slot="dialog-description" className={cn("text-muted-foreground text-sm", className)} {...props} /> ); } export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, } ``` -------------------------------------------------------------------------------- /src/components/ui/drawer.jsx: -------------------------------------------------------------------------------- ```javascript "use client" import * as React from "react" import { Drawer as DrawerPrimitive } from "vaul" import { cn } from "@/lib/utils" function Drawer({ ...props }) { return <DrawerPrimitive.Root data-slot="drawer" {...props} />; } function DrawerTrigger({ ...props }) { return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />; } function DrawerPortal({ ...props }) { return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />; } function DrawerClose({ ...props }) { return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />; } function DrawerOverlay({ className, ...props }) { return ( <DrawerPrimitive.Overlay data-slot="drawer-overlay" className={cn( "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", className )} {...props} /> ); } function DrawerContent({ className, children, ...props }) { return ( <DrawerPortal data-slot="drawer-portal"> <DrawerOverlay /> <DrawerPrimitive.Content data-slot="drawer-content" className={cn( "group/drawer-content bg-background fixed z-50 flex h-auto flex-col", "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b", "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t", "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm", "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm", className )} {...props}> <div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" /> {children} </DrawerPrimitive.Content> </DrawerPortal> ); } function DrawerHeader({ className, ...props }) { return ( <div data-slot="drawer-header" className={cn( "flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left", className )} {...props} /> ); } function DrawerFooter({ className, ...props }) { return ( <div data-slot="drawer-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} /> ); } function DrawerTitle({ className, ...props }) { return ( <DrawerPrimitive.Title data-slot="drawer-title" className={cn("text-foreground font-semibold", className)} {...props} /> ); } function DrawerDescription({ className, ...props }) { return ( <DrawerPrimitive.Description data-slot="drawer-description" className={cn("text-muted-foreground text-sm", className)} {...props} /> ); } export { Drawer, DrawerPortal, DrawerOverlay, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, } ``` -------------------------------------------------------------------------------- /src/app/_components/LoginForm.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { supabaseBrowser } from "@/lib/supabaseBrowser"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { Loader2Icon, LockIcon } from "lucide-react"; import { Controller, useForm } from "react-hook-form"; export default function LoginForm() { const router = useRouter(); const [loading, setLoading] = useState(false); useEffect(() => { // set loading state false when route change complete const handleComplete = () => setLoading(false); // Listen to route change events router.events?.on("routeChangeComplete", handleComplete); router.events?.on("routeChangeError", handleComplete); return () => { router.events?.off("routeChangeComplete", handleComplete); router.events?.off("routeChangeError", handleComplete); }; }, [router]); const { control, handleSubmit, formState: { errors }, } = useForm({ defaultValues: { email: "", password: "", }, }); const onSubmit = async (data) => { try { setLoading(true); const supabase = supabaseBrowser(); const { error } = await supabase.auth.signInWithPassword({ email: data.email, password: data.password, }); if (error) throw error; await router.push("/dashboard"); } catch (err) { toast.error(err.message); setLoading(false); } }; return ( <div className="min-h-screen flex items-center justify-center px-4 bg-gradient-to-br from-[#fef6f3] to-[#f1f5f9]"> <Card className="w-full max-w-md shadow-xl border border-gray-200"> <CardHeader className="text-center space-y-2"> <div className="flex justify-center"> <div className="w-14 h-14 rounded-full bg-[#6d2315] text-white flex items-center justify-center text-2xl font-bold"> <LockIcon /> </div> </div> <CardTitle className="text-2xl font-bold text-[#6d2315]">Welcome Back</CardTitle> <p className="text-sm text-gray-500">Please login to your account</p> </CardHeader> <CardContent> <form onSubmit={handleSubmit(onSubmit)} className="space-y-5"> <div className="space-y-1"> <label htmlFor="email" className="text-sm font-medium text-gray-700"> Email </label> <Controller name="email" control={control} rules={{ required: "Email is required!" }} render={({ field }) => ( <Input {...field} id="email" type="email" placeholder="[email protected]" required /> )} /> {errors.email && ( <p role="alert" className="text-sm text-red-500"> {errors.email.message} </p> )} </div> <div className="space-y-1"> <label htmlFor="password" className="text-sm font-medium text-gray-700"> Password </label> <Controller name="password" control={control} rules={{ required: "Password is required!" }} render={({ field }) => ( <Input {...field} id="password" type="password" placeholder="••••••••" required /> )} /> {errors.password && ( <p role="alert" className="text-sm text-red-500"> {errors.password.message} </p> )} </div> {/* {error && <p className="text-sm text-red-500 text-center">{error}</p>} */} <Button type="submit" className="w-full bg-[#6d2315] hover:bg-[#591c10]" disabled={loading} > {loading ? ( <> <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> Logging in... </> ) : ( "Login" )} </Button> </form> </CardContent> </Card> </div> ); } ``` -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- ```css @import "tailwindcss"; @import "tw-animate-css"; @custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar: var(--sidebar); --color-chart-5: var(--chart-5); --color-chart-4: var(--chart-4); --color-chart-3: var(--chart-3); --color-chart-2: var(--chart-2); --color-chart-1: var(--chart-1); --color-ring: var(--ring); --color-input: var(--input); --color-border: var(--border); --color-destructive: var(--destructive); --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); --color-muted-foreground: var(--muted-foreground); --color-muted: var(--muted); --color-secondary-foreground: var(--secondary-foreground); --color-secondary: var(--secondary); --color-primary-foreground: var(--primary-foreground); --color-primary: var(--primary); --color-popover-foreground: var(--popover-foreground); --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); } :root { --radius: 0.625rem; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); --primary: oklch(0.205 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); --muted: oklch(0.97 0 0); --muted-foreground: oklch(0.556 0 0); --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); --sidebar-primary: oklch(0.205 0 0); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.97 0 0); --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); } .dark { --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.205 0 0); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.922 0 0); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.269 0 0); --muted-foreground: oklch(0.708 0 0); --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.269 0 0); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.556 0 0); } @layer base { * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; } } button { cursor: pointer; } ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/_components/InvoiceDownloadModal.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useRef, useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import InvoicePreview from "./InvoicePreview"; import { exportInvoiceToPng } from "@/lib/exportToPng"; import { formatDateFilename } from "@/lib/utils"; import { toast } from "sonner"; export default function InvoiceDownloadModal({ open, onOpenChange, invoice, invoiceItems }) { const invoiceRef = useRef(null); const hiddenRef = useRef(null); const [dataReady, setDataReady] = useState(false); const [isInvoiceReady, setIsInvoiceReady] = useState(false); const [isDownloading, setIsDownloading] = useState(false); const [shippingType, setShippingType] = useState(""); const handleDownload = async () => { if (isDownloading) { toast.error("Wait until download complete"); return; } if (!isInvoiceReady || !dataReady) { toast.error("Wait a second. Invoice is loading .."); return; } setIsDownloading(true); try { await document.fonts.ready; await new Promise((r) => setTimeout(r, 200)); const formattedName = `Invoice-${invoice.invoiceNumber}_${ invoice.buyerName }_${formatDateFilename(invoice?.invoiceDate).replaceAll("/", "")}.png` .replace(/\s+/g, "-") .toLowerCase(); await exportInvoiceToPng(hiddenRef.current, formattedName); } catch (error) { console.error("Download failed:", error); toast.error("Failed to export invoice"); } finally { setTimeout(() => setIsDownloading(false), 3000); } }; return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogHeader> <DialogTitle className="sr-only">Preview Invoice</DialogTitle> </DialogHeader> <DialogContent className="w-full md:max-w-7xl max-h-[90vh] overflow-y-auto p-0 mx-auto"> <div ref={hiddenRef} className="absolute -left-[9999px] top-0 bg-white p-2" style={{ width: "max-content", display: "inline-block", overflow: "visible", }} > <InvoicePreview invoice={invoice} invoiceItems={invoiceItems} onDataReady={setDataReady} oonReady={() => setIsInvoiceReady(true)} shippingType={shippingType} isDownloadVersion /> </div> <div className="flex flex-col md:flex-row gap-0"> {/* main content */} <div ref={invoiceRef} className="bg-white p-6 flex-1"> <InvoicePreview invoice={invoice} invoiceItems={invoiceItems} onDataReady={setDataReady} shippingType={shippingType} onReady={() => setIsInvoiceReady(true)} /> </div> {/* sidebar */} <div className="md:w-64 bg-white pt-5 flex flex-col items-center md:items-start text-center md:text-left"> <p className="mb-2 font-medium">Pilih Ongkir</p> <RadioGroup value={shippingType} onValueChange={setShippingType} className="flex flex-col gap-3" > {["", "sameday", "instan", "jne", "j&t"].map((value) => ( <div key={value} className="flex items-center space-x-2"> <RadioGroupItem value={value} id={value || "default"} /> <Label htmlFor={value || "default"} className="text-xs"> {value === "" ? "Default (tanpa opsi)" : value} </Label> </div> ))} </RadioGroup> </div> </div> {/* desktop view */} <div className="hidden md:block sticky bottom-0 z-10 bg-white p-4 border-t border-gray-200 shadow-sm text-center"> <Button onClick={handleDownload} disabled={isDownloading}> {isDownloading ? "Please wait ..." : "Download PNG"} </Button> </div> {/* mobile view */} <div className="md:hidden sticky bottom-0 z-10 bg-white p-4 border-t border-gray-200"> <Button className="w-full" onClick={handleDownload} disabled={isDownloading}> {isDownloading ? "Please wait ..." : "Download PNG"} </Button> </div> </DialogContent> </Dialog> ); } ``` -------------------------------------------------------------------------------- /src/components/ui/command.jsx: -------------------------------------------------------------------------------- ```javascript "use client" import * as React from "react" import { Command as CommandPrimitive } from "cmdk" import { SearchIcon } from "lucide-react" import { cn } from "@/lib/utils" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog" function Command({ className, ...props }) { return ( <CommandPrimitive data-slot="command" className={cn( "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md", className )} {...props} /> ); } function CommandDialog({ title = "Command Palette", description = "Search for a command to run...", children, className, showCloseButton = true, ...props }) { return ( <Dialog {...props}> <DialogHeader className="sr-only"> <DialogTitle>{title}</DialogTitle> <DialogDescription>{description}</DialogDescription> </DialogHeader> <DialogContent className={cn("overflow-hidden p-0", className)} showCloseButton={showCloseButton}> <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> {children} </Command> </DialogContent> </Dialog> ); } function CommandInput({ className, ...props }) { return ( <div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3"> <SearchIcon className="size-4 shrink-0 opacity-50" /> <CommandPrimitive.Input data-slot="command-input" className={cn( "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", className )} {...props} /> </div> ); } function CommandList({ className, ...props }) { return ( <CommandPrimitive.List data-slot="command-list" className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)} {...props} /> ); } function CommandEmpty({ ...props }) { return (<CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />); } function CommandGroup({ className, ...props }) { return ( <CommandPrimitive.Group data-slot="command-group" className={cn( "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium", className )} {...props} /> ); } function CommandSeparator({ className, ...props }) { return ( <CommandPrimitive.Separator data-slot="command-separator" className={cn("bg-border -mx-1 h-px", className)} {...props} /> ); } function CommandItem({ className, ...props }) { return ( <CommandPrimitive.Item data-slot="command-item" className={cn( "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} /> ); } function CommandShortcut({ className, ...props }) { return ( <span data-slot="command-shortcut" className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)} {...props} /> ); } export { Command, CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, CommandSeparator, } ``` -------------------------------------------------------------------------------- /src/app/dashboard/size-pricing/_components/Table.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useState, useEffect, forwardRef, useImperativeHandle } from "react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import EditSizeModal from "./EditSizeModal"; import DeleteSizeModal from "./DeleteSizeModal"; import { ArrowUp, ArrowDown, Pencil, Trash2 } from "lucide-react"; import { getAllSizePrice } from "@/lib/actions/size-price/getAll"; const ITEMS_PER_PAGE = 10; const SizePriceTable = forwardRef(function SizePriceTable(props, ref) { const [size, setSize] = useState([]); const [sortOrder, setSortOrder] = useState("asc"); const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [selectedSize, setSelectedSize] = useState(null); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false); if (error) return <p className="text-red-500">Failed to fetch data: {error}</p>; const fetchData = async () => { const { data, error } = await getAllSizePrice(sortOrder); if (error) setError(error.message); else setSize(data); }; useEffect(() => { fetchData(); }, [sortOrder]); useImperativeHandle(ref, () => ({ refetch: fetchData, })); // pagination const totalPages = Math.ceil(size.length / ITEMS_PER_PAGE); const paginatedData = size.slice( (currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE ); return ( <Card className="p-4 bg-[#fffaf0] border border-[#f4e3d3] shadow-sm"> <div className="overflow-x-auto rounded-lg border border-[#fceee4]"> <table className="w-full text-sm"> <thead className="bg-[#fdf2e9] text-[#6D2315]"> <tr> <th className="px-4 py-2 text-left font-semibold cursor-pointer" onClick={() => { setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); setCurrentPage(1); // reset ke page 1 tiap kali sorting }} > <div className="flex items-center gap-1"> Size {sortOrder === "asc" ? ( <ArrowUp className="w-4 h-4" /> ) : ( <ArrowDown className="w-4 h-4" /> )} </div> </th> <th className="px-4 py-2 text-left font-semibold">Price</th> <th className="px-4 py-2 text-left font-semibold">Created At</th> <th className="px-4 py-2 text-left font-semibold">Action</th> </tr> </thead> <tbody> {paginatedData.map((data) => ( <tr key={data.id} className="border-t border-[#fceee4] hover:bg-[#fff3ec] transition-colors" > <td className="px-4 py-2 text-gray-800">{data.size}</td> <td className="px-4 py-2 text-gray-800"> Rp. {(data.price || 0).toLocaleString("id-ID")} </td> <td className="px-4 py-2 text-gray-800"> {new Date(data.createdAt).toLocaleString()} </td> <td className="px-4 py-2"> <div className="flex gap-2"> <Button onClick={() => { setSelectedSize(data); setEditModalOpen(true); }} variant="ghost" size="icon" className="text-blue-500 hover:text-blue-600" > <Pencil className="h-4 w-4" /> </Button> <Button onClick={() => { setSelectedSize(data); setDeleteModalOpen(true); }} variant="ghost" size="icon" className="text-red-500 hover:text-red-600" > <Trash2 className="h-4 w-4" /> </Button> </div> </td> </tr> ))} </tbody> </table> </div> {/* Pagination */} <div className="mt-4 flex justify-end flex-wrap gap-2"> {Array.from({ length: totalPages }).map((_, idx) => { const page = idx + 1; return ( <Button key={page} onClick={() => setCurrentPage(page)} variant={page === currentPage ? "default" : "outline"} size="sm" className={page === currentPage ? "bg-[#6D2315] text-white" : ""} > {page} </Button> ); })} </div> <EditSizeModal open={editModalOpen} onOpenChange={setEditModalOpen} data={selectedSize} onSuccess={(updatedSize) => { setSize((prev) => prev.map((p) => (p.id === updatedSize.id ? updatedSize : p))); setSelectedSize(null); }} /> <DeleteSizeModal open={deleteModalOpen} onOpenChange={setDeleteModalOpen} sizeId={selectedSize?.id} onSuccess={() => { setSize((prev) => prev.filter((p) => p.id !== selectedSize?.id)); setSelectedSize(null); }} /> </Card> ); }); export default SizePriceTable; ``` -------------------------------------------------------------------------------- /src/app/dashboard/products/_components/ProductTable.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useState, useEffect, forwardRef, useImperativeHandle } from "react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import ProductDeleteModal from "./ProductDeleteModal"; import ProductEditModal from "./ProductEditModal"; import { ArrowUp, ArrowDown, Pencil, Trash2 } from "lucide-react"; import { getAllProducts } from "@/lib/actions/products/getAllProducts"; const ITEMS_PER_PAGE = 10; const ProductTable = forwardRef(function ProductTable(props, ref) { const [products, setProducts] = useState([]); const [sortOrder, setSortOrder] = useState("asc"); const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [selectedProduct, setSelectedProduct] = useState(null); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); if (error) return <p className="text-red-500">Failed to fetch data: {error}</p>; const fetchData = async () => { const { data, error } = await getAllProducts(sortOrder); if (error) setError(error.message); else setProducts(data); }; useEffect(() => { fetchData(); }, [sortOrder]); useImperativeHandle(ref, () => ({ refetch: fetchData, })); const filteredData = products.filter((product) => { const query = searchQuery.toLowerCase(); return product.name.toLowerCase().includes(query); }); // pagination const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE); const paginatedData = filteredData.slice( (currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE ); return ( <Card className="p-4 bg-[#fffaf0] border border-[#f4e3d3] shadow-sm"> {/* Search Field */} <Input type="text" placeholder="Search product..." value={searchQuery} onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }} className="mb-4 w-full sm:w-64 px-3 py-2 text-sm border border-[#6D2315] rounded-md focus:outline-none focus:ring-2 focus:ring-[#6D2315]" /> {/* Table */} <div className="overflow-x-auto rounded-lg border border-[#fceee4]"> <table className="w-full text-sm"> <thead className="bg-[#fdf2e9] text-[#6D2315]"> <tr> <th className="px-4 py-2 text-left font-semibold cursor-pointer" onClick={() => { setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); setCurrentPage(1); }} > <div className="flex items-center gap-1"> Product Name {sortOrder === "asc" ? ( <ArrowUp className="w-4 h-4" /> ) : ( <ArrowDown className="w-4 h-4" /> )} </div> </th> <th className="px-4 py-2 text-left font-semibold">Description</th> <th className="px-4 py-2 text-left font-semibold">Created At</th> <th className="px-4 py-2 text-left font-semibold">Action</th> </tr> </thead> <tbody> {paginatedData.map((product) => ( <tr key={product.id} className="border-t border-[#fceee4] hover:bg-[#fff3ec] transition-colors" > <td className="px-4 py-2 text-gray-800">{product.name}</td> <td className="px-4 py-2 text-gray-600">{product.description || "-"}</td> <td className="px-4 py-2 text-gray-500"> {new Date(product.createdAt).toLocaleString()} </td> <td className="px-4 py-2"> <div className="flex gap-2"> <Button onClick={() => { setSelectedProduct(product); setEditModalOpen(true); }} variant="ghost" size="icon" className="text-blue-500 hover:text-blue-600" > <Pencil className="h-4 w-4" /> </Button> <Button onClick={() => { setSelectedProduct(product); setDeleteModalOpen(true); }} variant="ghost" size="icon" className="text-red-500 hover:text-red-600" > <Trash2 className="h-4 w-4" /> </Button> </div> </td> </tr> ))} </tbody> </table> </div> {/* Pagination */} <div className="mt-4 flex justify-end flex-wrap gap-2"> {Array.from({ length: totalPages }).map((_, idx) => { const page = idx + 1; return ( <Button key={page} onClick={() => setCurrentPage(page)} variant={page === currentPage ? "default" : "outline"} size="sm" className={page === currentPage ? "bg-[#6D2315] text-white" : ""} > {page} </Button> ); })} </div> {/* Modals */} <ProductDeleteModal open={deleteModalOpen} onOpenChange={setDeleteModalOpen} productId={selectedProduct?.id} onSuccess={() => { setProducts((prev) => prev.filter((p) => p.id !== selectedProduct?.id)); setSelectedProduct(null); }} /> <ProductEditModal open={editModalOpen} onOpenChange={setEditModalOpen} product={selectedProduct} onSuccess={(updatedProduct) => { setProducts((prev) => prev.map((p) => (p.id === updatedProduct.id ? updatedProduct : p))); setSelectedProduct(null); }} /> </Card> ); }); export default ProductTable; ``` -------------------------------------------------------------------------------- /src/components/ui/select.jsx: -------------------------------------------------------------------------------- ```javascript "use client" import * as React from "react" import * as SelectPrimitive from "@radix-ui/react-select" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { cn } from "@/lib/utils" function Select({ ...props }) { return <SelectPrimitive.Root data-slot="select" {...props} />; } function SelectGroup({ ...props }) { return <SelectPrimitive.Group data-slot="select-group" {...props} />; } function SelectValue({ ...props }) { return <SelectPrimitive.Value data-slot="select-value" {...props} />; } function SelectTrigger({ className, size = "default", children, ...props }) { return ( <SelectPrimitive.Trigger data-slot="select-trigger" data-size={size} className={cn( "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props}> {children} <SelectPrimitive.Icon asChild> <ChevronDownIcon className="size-4 opacity-50" /> </SelectPrimitive.Icon> </SelectPrimitive.Trigger> ); } function SelectContent({ className, children, position = "popper", ...props }) { return ( <SelectPrimitive.Portal> <SelectPrimitive.Content data-slot="select-content" className={cn( "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )} position={position} {...props}> <SelectScrollUpButton /> <SelectPrimitive.Viewport className={cn("p-1", position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1")}> {children} </SelectPrimitive.Viewport> <SelectScrollDownButton /> </SelectPrimitive.Content> </SelectPrimitive.Portal> ); } function SelectLabel({ className, ...props }) { return ( <SelectPrimitive.Label data-slot="select-label" className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} {...props} /> ); } function SelectItem({ className, children, ...props }) { return ( <SelectPrimitive.Item data-slot="select-item" className={cn( "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", className )} {...props}> <span className="absolute right-2 flex size-3.5 items-center justify-center"> <SelectPrimitive.ItemIndicator> <CheckIcon className="size-4" /> </SelectPrimitive.ItemIndicator> </span> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> </SelectPrimitive.Item> ); } function SelectSeparator({ className, ...props }) { return ( <SelectPrimitive.Separator data-slot="select-separator" className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} {...props} /> ); } function SelectScrollUpButton({ className, ...props }) { return ( <SelectPrimitive.ScrollUpButton data-slot="select-scroll-up-button" className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}> <ChevronUpIcon className="size-4" /> </SelectPrimitive.ScrollUpButton> ); } function SelectScrollDownButton({ className, ...props }) { return ( <SelectPrimitive.ScrollDownButton data-slot="select-scroll-down-button" className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}> <ChevronDownIcon className="size-4" /> </SelectPrimitive.ScrollDownButton> ); } export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, } ``` -------------------------------------------------------------------------------- /src/app/dashboard/page.jsx: -------------------------------------------------------------------------------- ```javascript import Link from "next/link"; import { redirect, unauthorized } from "next/navigation"; import { supabaseServer } from "@/lib/supabaseServer"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { getPageTitle, getStatusVariant, toTitleCase } from "@/lib/utils"; import { AlertTriangle, CheckCircle, FileText, Package, Users, Wallet } from "lucide-react"; export const metadata = { title: getPageTitle("Dashboard"), }; export default async function Dashboard() { const supabase = await supabaseServer(); const { data: { session }, } = await supabase.auth.getSession(); if (!session) { unauthorized(); } // const user = session.user; // get all invoices const { data: invoices } = await supabase .from("Invoice") .select("*") .order("invoiceDate", { ascending: false }); // get total invoice const totalInvoices = invoices.length; // get latest invoice data const latestInvoices = invoices.slice(0, 5); // count paid invoices const invoicesSuccess = invoices.filter((inv) => inv.status === "success").length; // count unpaid invoices const invoicesUnpaid = invoices.filter((inv) => inv.status === "pending").length; // count total customers (unique) const { data } = await supabase.from("Invoice").select("buyerName"); const uniqueCustomers = new Set(data.map((d) => d.buyerName.trim().toLowerCase())); const totalCustomers = uniqueCustomers.size; const totalAmount = invoices ?.filter((inv) => inv.status === "success") .reduce((acc, curr) => acc + curr.totalPrice, 0) || 0; // get total products const { count: totalProducts } = await supabase .from("Product") .select("*", { count: "exact", head: true }); return ( <div className="grid gap-6"> {/* Welcome Card */} <Card className="bg-[#fffaf0] border border-[#f4e3d3] shadow-sm"> <CardHeader> <CardTitle className="text-xl text-[#6D2315] font-bold">Welcome back, Admin 👋</CardTitle> </CardHeader> <CardContent> <p className="text-sm text-gray-600">Manage your dashboard here.</p> <div className="mt-4"> <Link href="/dashboard/invoices/create"> <Button className="bg-[#6D2315] hover:bg-[#591c10] text-white"> + Create Invoice </Button> </Link> </div> </CardContent> </Card> {/* Statistik Ringkas */} <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> {/* Total Invoices */} <Card className="bg-[#fef6f3] border-0 shadow-sm text-[#6D2315]"> <CardHeader> <CardTitle className="text-sm font-medium flex items-center gap-2"> <FileText className="w-4 h-4 text-[#6D2315]" /> Total Invoices </CardTitle> </CardHeader> <CardContent> <p className="text-3xl font-bold">{totalInvoices || 0}</p> </CardContent> </Card> {/* Total Income */} <Card className="bg-[#fdf2e9] border-0 shadow-sm text-[#92400e]"> <CardHeader> <CardTitle className="text-sm font-medium flex items-center gap-2"> <Wallet className="w-4 h-4 text-[#92400e]" /> Total Income </CardTitle> </CardHeader> <CardContent> <p className="text-3xl font-bold">Rp {totalAmount.toLocaleString("id-ID")}</p> </CardContent> </Card> {/* Total Products */} <Card className="bg-[#fef9e7] border-0 shadow-sm text-[#92400e]"> <CardHeader> <CardTitle className="text-sm font-medium flex items-center gap-2"> <Package className="w-4 h-4 text-[#92400e]" /> Total Products </CardTitle> </CardHeader> <CardContent> <p className="text-3xl font-bold">{totalProducts}</p> </CardContent> </Card> {/* Paid Invoices */} <Card className="bg-[#f0f9f5] border-0 shadow-sm text-[#065f46]"> <CardHeader> <CardTitle className="text-sm font-medium flex items-center gap-2"> <CheckCircle className="w-4 h-4 text-[#065f46]" /> Paid Invoices </CardTitle> </CardHeader> <CardContent> <p className="text-3xl font-bold">{invoicesSuccess}</p> </CardContent> </Card> {/* Unpaid Invoices */} <Card className="bg-[#fef2f2] border-0 shadow-sm text-[#991b1b]"> <CardHeader> <CardTitle className="text-sm font-medium flex items-center gap-2"> <AlertTriangle className="w-4 h-4 text-[#991b1b]" /> Unpaid Invoices </CardTitle> </CardHeader> <CardContent> <p className="text-3xl font-bold">{invoicesUnpaid}</p> </CardContent> </Card> {/* Total Customers */} <Card className="bg-[#e8f0fe] border-0 shadow-sm text-[#1e3a8a]"> <CardHeader> <CardTitle className="text-sm font-medium flex items-center gap-2"> <Users className="w-4 h-4 text-[#1e3a8a]" /> Total Customers </CardTitle> </CardHeader> <CardContent> <p className="text-3xl font-bold">{totalCustomers}</p> </CardContent> </Card> </div> {/* Latest Invoices */} <Card className="bg-white border border-[#f4e3d3] shadow-sm"> <CardHeader> <CardTitle className="text-[#6D2315] font-medium">Latest Invoices</CardTitle> </CardHeader> <CardContent className="space-y-3"> {latestInvoices && latestInvoices.length > 0 ? ( latestInvoices.map((inv) => ( <div key={inv.id} className="flex items-center justify-between bg-[#fefaf7] hover:bg-[#fff3ec] transition-colors duration-150 border border-[#fceee4] rounded-md p-3" > {/* Left Section: Icon + Info */} <div className="flex items-center gap-3"> <div className="bg-[#fdf0e6] text-[#6D2315] p-2 rounded-md"> <FileText className="w-5 h-5" /> </div> <div> <p className="font-medium text-gray-800">Invoice #{inv.invoiceNumber}</p> <p className="text-sm text-gray-500">{toTitleCase(inv.buyerName)}</p> </div> </div> {/* Right Section: Amount + Status */} <div className="text-right space-y-1"> <p className="font-semibold text-gray-800"> Rp {inv.totalPrice.toLocaleString("id-ID")} </p> <span className={getStatusVariant(inv.status)}>{toTitleCase(inv.status)}</span> </div> </div> )) ) : ( <p className="text-sm text-gray-500">No invoice data.</p> )} </CardContent> </Card> </div> ); } ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/Table.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useState, useEffect, forwardRef, useImperativeHandle } from "react"; import { useRouter } from "next/navigation"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import DeleteInvoiceModal from "./_components/DeleteInvoiceModal"; import InvoiceDownloadModal from "./_components/InvoiceDownloadModal"; import { getAllInvoice } from "@/lib/actions/invoice/getAll"; import { getInvoiceWithItems } from "@/lib/actions/invoice/getInvoiceWithItem"; import { formatInvoiceDateTime, getStatusVariant, toTitleCase } from "@/lib/utils"; import { ArrowUp, ArrowDown, Pencil, Trash2, Download } from "lucide-react"; const ITEMS_PER_PAGE = 10; const InvoicesTable = forwardRef(function InvoicesTable(props, ref) { const router = useRouter(); const [invoice, setInvoice] = useState([]); const [sortOrder, setSortOrder] = useState("desc"); const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [selectedInvoice, setSelectedInvoice] = useState(null); const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [downloadModalOpen, setDownloadModalOpen] = useState(false); const [invoiceItems, setInvoiceItems] = useState([]); if (error) return <p className="text-red-500">Failed to fetch data: {error}</p>; const fetchData = async () => { const { data, error } = await getAllInvoice(sortOrder); if (error) setError(error.message); else setInvoice(data); }; useEffect(() => { fetchData(); }, [sortOrder]); useImperativeHandle(ref, () => ({ refetch: fetchData, })); const filteredData = invoice.filter((inv) => { const query = searchQuery.toLowerCase(); return ( inv.invoiceNumber.toLowerCase().includes(query) || inv.buyerName.toLowerCase().includes(query) ); }); // pagination const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE); const paginatedData = filteredData.slice( (currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE ); const handleDownload = async (invoiceId) => { const { invoice, items } = await getInvoiceWithItems(invoiceId); setSelectedInvoice(invoice); setInvoiceItems(items); setDownloadModalOpen(true); }; return ( <Card className="p-4 bg-[#fffaf0] border border-[#f4e3d3] shadow-sm"> <Input type="text" placeholder="Search by name/inv.number" value={searchQuery} onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }} className="mb-4 w-full sm:w-64 px-3 py-2 text-sm border border-[#6D2315] rounded-md focus:outline-none focus:ring-2 focus:ring-[#6D2315]" /> <div className="overflow-x-auto rounded-lg border border-[#fceee4]"> <table className="w-full text-sm"> <thead className="bg-[#fdf2e9] text-[#6D2315]"> <tr> <th className="px-4 py-2 text-left font-semibold cursor-pointer" onClick={() => { setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); setCurrentPage(1); // reset ke page 1 tiap kali sorting }} > <div className="flex items-center gap-1"> Invoice Number {sortOrder === "desc" ? ( <ArrowDown className="w-4 h-4" /> ) : ( <ArrowUp className="w-4 h-4" /> )} </div> </th> <th className="px-4 py-2 text-left font-semibold">Customer</th> <th className="px-4 py-2 text-left font-semibold">Total Price</th> <th className="px-4 py-2 text-left font-semibold">Date</th> <th className="px-4 py-2 text-left font-semibold">Status</th> <th className="px-4 py-2 text-left font-semibold">Action</th> </tr> </thead> <tbody> {paginatedData && paginatedData.length > 0 ? ( paginatedData.map((data) => ( <tr key={data.id} className="border-t border-[#fceee4] hover:bg-[#fff3ec] transition-colors" > <td className="px-4 py-2 text-gray-800">{data.invoiceNumber}</td> <td className="px-4 py-2 text-gray-800">{toTitleCase(data.buyerName)}</td> <td className="px-4 py-2 text-gray-800"> Rp. {(data.totalPrice || 0).toLocaleString("id-ID")} </td> <td className="px-4 py-2 text-gray-800"> {formatInvoiceDateTime(data.invoiceDate, data.createdAt)} </td> <td className="px-4 py-2 text-gray-800"> <span className={getStatusVariant(data.status)}> {toTitleCase(data.status)} </span> </td> <td className="px-4 py-2"> <div className="flex gap-2"> <Button onClick={() => { router.push(`/dashboard/invoices/${data.invoiceNumber}`); }} variant="ghost" size="icon" className="text-blue-500 hover:text-blue-600" > <Pencil className="h-4 w-4" /> </Button> <Button onClick={() => { setSelectedInvoice(data); setDeleteModalOpen(true); }} variant="ghost" size="icon" className="text-red-500 hover:text-red-600" > <Trash2 className="h-4 w-4" /> </Button> <Button onClick={() => handleDownload(data.id)} variant="ghost" size="icon" className="text-green-500 hover:text-green-600" > <Download className="h-4 w-4" /> </Button> </div> </td> </tr> )) ) : ( <tr> <td colSpan="6" className="px-4 py-6 text-center text-gray-500"> No invoice data </td> </tr> )} </tbody> </table> </div> {/* Pagination */} <div className="mt-4 flex justify-end flex-wrap gap-2"> {Array.from({ length: totalPages }).map((_, idx) => { const page = idx + 1; return ( <Button key={page} onClick={() => setCurrentPage(page)} variant={page === currentPage ? "default" : "outline"} size="sm" className={page === currentPage ? "bg-[#6D2315] hover:bg-[#8d2e1c] text-white" : ""} > {page} </Button> ); })} </div> <DeleteInvoiceModal open={deleteModalOpen} onOpenChange={setDeleteModalOpen} invoiceId={selectedInvoice?.id} onSuccess={() => { setInvoice((prev) => prev.filter((p) => p.id !== selectedInvoice?.id)); setSelectedInvoice(null); }} /> <InvoiceDownloadModal open={downloadModalOpen} onOpenChange={setDownloadModalOpen} invoice={selectedInvoice} invoiceItems={invoiceItems} /> </Card> ); }); export default InvoicesTable; ``` -------------------------------------------------------------------------------- /src/components/ui/calendar.jsx: -------------------------------------------------------------------------------- ```javascript "use client" import * as React from "react" import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, } from "lucide-react" import { DayPicker, getDefaultClassNames } from "react-day-picker"; import { cn } from "@/lib/utils" import { Button, buttonVariants } from "@/components/ui/button" function Calendar({ className, classNames, showOutsideDays = true, captionLayout = "label", buttonVariant = "ghost", formatters, components, ...props }) { const defaultClassNames = getDefaultClassNames() return ( <DayPicker showOutsideDays={showOutsideDays} className={cn( "bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent", String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, className )} captionLayout={captionLayout} formatters={{ formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }), ...formatters, }} classNames={{ root: cn("w-fit", defaultClassNames.root), months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months), month: cn("flex flex-col w-full gap-4", defaultClassNames.month), nav: cn( "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", defaultClassNames.nav ), button_previous: cn( buttonVariants({ variant: buttonVariant }), "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", defaultClassNames.button_previous ), button_next: cn( buttonVariants({ variant: buttonVariant }), "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", defaultClassNames.button_next ), month_caption: cn( "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", defaultClassNames.month_caption ), dropdowns: cn( "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", defaultClassNames.dropdowns ), dropdown_root: cn( "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", defaultClassNames.dropdown_root ), dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown), caption_label: cn("select-none font-medium", captionLayout === "label" ? "text-sm" : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", defaultClassNames.caption_label), table: "w-full border-collapse", weekdays: cn("flex", defaultClassNames.weekdays), weekday: cn( "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", defaultClassNames.weekday ), week: cn("flex w-full mt-2", defaultClassNames.week), week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header), week_number: cn( "text-[0.8rem] select-none text-muted-foreground", defaultClassNames.week_number ), day: cn( "relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none", defaultClassNames.day ), range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start), range_middle: cn("rounded-none", defaultClassNames.range_middle), range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), today: cn( "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", defaultClassNames.today ), outside: cn( "text-muted-foreground aria-selected:text-muted-foreground", defaultClassNames.outside ), disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled), hidden: cn("invisible", defaultClassNames.hidden), ...classNames, }} components={{ Root: ({ className, rootRef, ...props }) => { return (<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />); }, Chevron: ({ className, orientation, ...props }) => { if (orientation === "left") { return (<ChevronLeftIcon className={cn("size-4", className)} {...props} />); } if (orientation === "right") { return (<ChevronRightIcon className={cn("size-4", className)} {...props} />); } return (<ChevronDownIcon className={cn("size-4", className)} {...props} />); }, DayButton: CalendarDayButton, WeekNumber: ({ children, ...props }) => { return ( <td {...props}> <div className="flex size-(--cell-size) items-center justify-center text-center"> {children} </div> </td> ); }, ...components, }} {...props} /> ); } function CalendarDayButton({ className, day, modifiers, ...props }) { const defaultClassNames = getDefaultClassNames() const ref = React.useRef(null) React.useEffect(() => { if (modifiers.focused) ref.current?.focus() }, [modifiers.focused]) return ( <Button ref={ref} variant="ghost" size="icon" data-day={day.date.toLocaleDateString()} data-selected-single={ modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle } data-range-start={modifiers.range_start} data-range-end={modifiers.range_end} data-range-middle={modifiers.range_middle} className={cn( "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70", defaultClassNames.day, className )} {...props} /> ); } export { Calendar, CalendarDayButton } ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/_components/InvoicePreview.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { forwardRef, useEffect, useState } from "react"; import Image from "next/image"; import { getAllProducts } from "@/lib/actions/products/getAllProducts"; import { getAllSizePrice } from "@/lib/actions/size-price/getAll"; import { cn, formatDateFilename, toTitleCase } from "@/lib/utils"; const InvoicePreview = forwardRef( ( { invoice, invoiceItems, shippingType, onReady, onDataReady, isDownloadVersion = false }, ref ) => { const [products, setProducts] = useState([]); const [sizes, setSizes] = useState([]); const [items, setItems] = useState([]); useEffect(() => { const fetchData = async () => { const { data: productsData } = await getAllProducts(); const { data: sizeData } = await getAllSizePrice(); setProducts(productsData || []); setSizes(sizeData || []); }; fetchData(); }, []); useEffect(() => { if (invoiceItems?.length && products.length && sizes.length) { const mappedItems = invoiceItems.map((item) => { const product = products.find((p) => p.id === item.productId); const size = sizes.find((s) => s.id === item.sizePriceId); const price = size?.price || 0; const discount = item.discountAmount || 0; const quantity = item.quantity; return { productName: product?.name || "Unknown", sizeName: size?.size || "Unknown", quantity, price, discountAmount: discount, total: quantity * price - discount, }; }); setItems(mappedItems); onDataReady?.(true); } else { onDataReady?.(false); } }, [invoiceItems, products, sizes]); useEffect(() => { if (!items.length) return; const timer = setTimeout(() => { onReady?.(); }, 0); return () => clearTimeout(timer); }, [items]); const subtotal = items.reduce((acc, item) => acc + item.total, 0); const discount = invoice.discount || 0; const discountPercent = subtotal > 0 ? (discount / subtotal) * 100 : 0; const TOTAL_GAP_ROWS = 10; const gapRows = Math.max(0, TOTAL_GAP_ROWS - items.length); return ( <> <style jsx global>{` @import url("https://fonts.googleapis.com/css2?family=Pattaya&family=Poppins:wght@400;700&display=swap"); .invoice-content { font-family: "Poppins", sans-serif; } .thanks-msg { font-family: "Pattaya", cursive; } .invoice-content * { box-sizing: border-box; } .invoice-content table td, .invoice-content table th { padding: 8px 12px; /* ~px-3 py-2 */ white-space: nowrap; } `}</style> <div ref={ref} className={cn( "border border-[#6D2315] font-sans text-sm text-gray-900 invoice-content", isDownloadVersion ? "w-[1080px] p-3 overflow-visible" : "w-full md:w-[1080px] overflow-x-auto p-4" )} > <div className={cn("flex w-full", isDownloadVersion ? "flex-row" : "flex-col md:flex-row")} > {/* LEFT SECTION */} <div className={cn( "pr-4 border-r border-[#6D2315] space-y-6", isDownloadVersion ? "w-1/3" : "w-full md:w-1/3" )} > <div className="flex justify-center mb-4"> <div className="w-24 h-24 relative"> <Image src="/logo.png" alt="Logo" fill className="object-cover rounded-full" /> </div> </div> <h2 className="text-center font-bold text-[#6b1d1d] uppercase text-lg pb-7"> cheese stick koe </h2> <div> <h4 className="text-[#6b1d1d] font-semibold text-xs mb-1 uppercase"> diberikan kepada </h4> <p className="text-sm">{toTitleCase(invoice?.buyerName)}</p> </div> <div> <h4 className="text-[#6b1d1d] font-semibold text-xs mb-1 uppercase">detail bank</h4> <div className="text-sm space-y-2"> <div> <span className="font-semibold uppercase">bri</span> <br /> Ermi Sayekti Endahwati <br /> 0122-01-012734-53-8 </div> <div> <span className="font-semibold uppercase">bca</span> <br /> Ermi Sayekti Endahwati <br /> 524-5031-928 </div> </div> </div> <figure className="space-y-2"> {/* Image */} <figcaption className="text-xs text-left font-bold italic"> QRIS a.n Cheese Stick Koe </figcaption> <div className="flex justify-center mt-5"> <div className="relative w-38 h-38"> <Image src="/qris.png" alt="Icon" fill className="object-contain" /> </div> </div> {/* Caption */} </figure> {/* Thank You Text */} <div className="text-center pt-5 font-bold text-2xl text-[#6b1d1d] thanks-msg uppercase"> terima kasih </div> </div> {/* RIGHT SECTION */} <div className="md:w-2/3 w-full pl-6 space-y-6 mt-6 md:mt-0"> <div className="text-center border-b border-[#6D2315] pb-2"> <h1 className="font-bold text-lg uppercase">invoice pembayaran</h1> <div className="flex justify-between text-xs mt-1"> <span>Invoice No. {invoice?.invoiceNumber}</span> <span className="whitespace-nowrap"> {formatDateFilename(invoice?.invoiceDate)} </span> </div> </div> <div className={cn( !isDownloadVersion && "overflow-x-auto max-w-full", isDownloadVersion && "overflow-hidden" )} > <table className="text-left text-sm" style={{ border: "1.5px solid #6D2315", tableLayout: "auto", width: "100%", }} > <thead style={{ backgroundColor: "white", borderBottom: "1.5px solid #6D2315" }} className="uppercase" > <tr> <th className="px-2.5 py-2 whitespace-nowrap">item</th> <th className="px-2.5 py-2 whitespace-nowrap">ukuran</th> <th className="px-2.5 py-2 whitespace-nowrap">jml</th> <th className="px-2.5 py-2 whitespace-nowrap">harga</th> <th className="px-2.5 py-2 whitespace-nowrap">diskon</th> <th className="px-2.5 py-2 whitespace-nowrap">total</th> </tr> </thead> <tbody> {items.map((item, i) => ( <tr key={i}> <td className="px-2.5 py-2 whitespace-nowrap">{item.productName}</td> <td className="px-2.5 py-2 whitespace-nowrap text-center"> {item.sizeName} </td> <td className="px-2.5 py-2 whitespace-nowrap text-center"> {item.quantity} </td> <td className="px-2.5 py-2 whitespace-nowrap">{`Rp ${item.price.toLocaleString( "id-ID" )}`}</td> <td className={`px-2.5 py-2 whitespace-nowrap text-center ${ item.discountAmount > 0 ? "text-green-600" : "" }`} > {item.discountAmount ? `-Rp ${item.discountAmount.toLocaleString("id-ID")}` : "-"} </td> <td className="p-1 whitespace-nowrap"> {item.total ? `Rp ${item.total.toLocaleString("id-ID")}` : "-"} </td> </tr> ))} {Array.from({ length: gapRows }).map((_, idx) => ( <tr key={`gap-${idx}`}> <td> </td> <td></td> <td></td> <td></td> <td></td> <td></td> <td></td> </tr> ))} {/* Subtotal */} <tr style={{ borderTop: "1.5px solid #6D2315" }}> <td colSpan="5" className="px-2.5 py-2 text-right uppercase"> Sub Total : </td> <td className="px-2.5 py-2 whitespace-nowrap"> Rp {subtotal.toLocaleString("id-ID")} </td> </tr> {/* Diskon */} {invoice?.discount > 0 && ( <tr className="text-green-500"> <td colSpan="5" className="px-2.5 py-2 text-right uppercase"> diskon ({discountPercent.toFixed(2)}%) : </td> <td className="px-2.5 py-2 whitespace-nowrap"> -Rp{" "} {typeof invoice.discount === "number" ? invoice.discount.toLocaleString("id-ID") : "0"} </td> </tr> )} {/* Ongkir */} <tr> <td colSpan="5" className="px-2.5 py-2 text-right uppercase"> {shippingType ? `ongkir (${shippingType}) :` : "ongkir :"} </td> <td className="px-2.5 py-2 whitespace-nowrap"> Rp {invoice?.shipping?.toLocaleString("id-ID")} </td> </tr> {/* Total */} <tr style={{ borderBottom: "1.5px solid #6D2315" }}> <td colSpan="5" className="px-2.5 py-2 text-right font-bold text-red-700 uppercase" > jumlah yang harus dibayar : </td> <td className="px-2.5 py-2 font-bold text-red-700 whitespace-nowrap"> Rp{" "} {typeof invoice.totalPrice === "number" ? invoice.totalPrice.toLocaleString("id-ID") : "0"} </td> </tr> </tbody> </table> </div> {/* Disclaimer */} <div className="text-red-600 pt-4 border-t border-gray-200"> <span className="font-semibold text-sm">*Disclaimer</span> <br /> <span className="text-black text-sm"> Segala kerusakan yang terjadi selama pengiriman menjadi tanggung jawab pihak ekspedisi. Namun, kami siap membantu proses klaim ke pihak ekspedisi apabila terjadi kendala selama pengiriman. </span> </div> </div> </div> </div> </> ); } ); export default InvoicePreview; ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/create/CreateInvoicePage.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useEffect, useState } from "react"; import Link from "next/link"; import { Controller, useForm } from "react-hook-form"; import { supabaseBrowser } from "@/lib/supabaseBrowser"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import DatePicker from "@/components/dashboard/DatePicker"; import ProductCombobox from "./_components/ProductsCombobox"; import SizeCombobox from "./_components/SizeCombobox"; import { ChevronRight, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { getAllProducts } from "@/lib/actions/products/getAllProducts"; import { getAllSizePrice } from "@/lib/actions/size-price/getAll"; import { submitInvoice } from "@/lib/actions/invoice/submitInvoice"; import { calculateDiscountAmount, calculateDiscountPercent } from "@/lib/utils"; export default function CreateInvoicePage() { const supabase = supabaseBrowser(); const [user, setUser] = useState(null); const [products, setProducts] = useState([]); const [sizes, setSizes] = useState([]); const [invoiceDate, setInvoiceDate] = useState(new Date().toISOString()); const [lastInvoiceNumber, setLastInvoiceNumber] = useState(null); const [shippingPrice, setShippingPrice] = useState(0); const { control, handleSubmit, formState: { errors }, reset, } = useForm({ defaultValues: { invoiceNumber: "", buyerName: "", }, mode: "onChange", }); const resetForm = () => { setInvoiceDate(new Date().toISOString()); setShippingPrice(0); setDiscountInput(0); setItems([createEmptyItem()]); }; const createEmptyItem = () => ({ productId: "", sizePriceId: "", quantity: 1, price: 0, discountMode: "amount" | "percent", discountInput: "", discountAmount: 0, total: 0, }); const [items, setItems] = useState([createEmptyItem()]); // general discount const [discountMode, setDiscountMode] = useState("amount"); const [discountInput, setDiscountInput] = useState(0); useEffect(() => { const fetchInitialData = async () => { const [{ data: userData }, { data: lastInvoice }] = await Promise.all([ supabase.auth.getUser(), supabase .from("Invoice") .select("invoiceNumber, invoiceDate") .order("invoiceDate", { ascending: false }) .limit(1) .single(), ]); if (userData?.user) setUser(userData.user); if (lastInvoice) setLastInvoiceNumber(lastInvoice.invoiceNumber); }; fetchInitialData(); }, []); const addItem = () => { setItems([...items, createEmptyItem()]); }; const removeItem = (index) => { setItems((prev) => prev.filter((_, i) => i !== index)); }; useEffect(() => { const fetchOptions = async () => { const [{ data: prods }, { data: szs }] = await Promise.all([ getAllProducts(), getAllSizePrice(), ]); setProducts(prods || []); setSizes(szs || []); }; fetchOptions(); }, []); // calculate each item total and subtotal const updateItemField = (item, field, value, mode = null) => { if (field === "sizePriceId") { const selectedSize = sizes.find((s) => s.id === value); item.sizePriceId = value; item.price = selectedSize?.price || 0; } else if (field === "quantity") { const parsed = parseInt(value, 10); item.quantity = isNaN(parsed) || parsed < 1 ? 1 : parsed; } else if (field === "price") { const parsed = parseInt(value, 10); item.price = isNaN(parsed) ? 0 : parsed; } else if (field === "discountMode") { item.discountMode = value; } else if (field === "discountInput") { item.discountInput = value; item.discountMode = mode; } else { item[field] = value; } const qty = item.quantity || 0; const price = item.price || 0; const rawTotal = qty * price; const discountAmount = calculateDiscountAmount({ quantity: item.quantity, price: item.price, discountInput: item.discountInput, discountMode: item.discountMode, }); item.discountAmount = discountAmount; item.total = rawTotal - discountAmount; }; const handleItemChange = (index, field, value, mode = null) => { const updatedItems = [...items]; const item = updatedItems[index]; updateItemField(item, field, value, mode); setItems(updatedItems); }; const subtotal = items.reduce((sum, item) => sum + item.total, 0); const discountAmount = discountMode === "percent" ? Math.round(((parseFloat(discountInput) || 0) / 100) * subtotal) : parseInt(discountInput) || 0; const discountPercent = discountMode === "amount" ? ((parseInt(discountInput) || 0) / subtotal) * 100 : parseFloat(discountInput) || 0; const totalPrice = subtotal + (parseInt(shippingPrice) || 0) - discountAmount; const onSubmit = async (data) => { if (!user) { toast.error("User not log in"); return; } const isInvalid = items.some((item) => !item.productId || !item.sizePriceId); if (isInvalid) { toast.error("You must add product and size before submitting!"); return; } const res = await submitInvoice({ invoiceNumber: data.invoiceNumber, buyerName: data.buyerName.trim().toLowerCase(), invoiceDate, shippingPrice, discountAmount, totalPrice, items, user, }); if (res.error) { toast.error(res.error); } else { toast.success(res.message); reset({ invoiceNumber: "", buyerName: "", }); resetForm(); } }; return ( <section className="w-full px-4 py-6 bg-[#fffaf0]"> <div className="bg-white rounded-xl shadow-md p-6 space-y-6 border border-[#f4e3d3]"> {/* Breadcrumbs (Mobile Only) */} <div className="block md:hidden text-sm text-gray-500 mb-4"> <nav className="flex items-center space-x-1"> <Link className="text-gray-400" href="/dashboard/invoices"> List Invoice </Link> <ChevronRight className="w-4 h-4 text-gray-400" /> <span className="text-gray-700 font-medium">Create Invoice</span> </nav> </div> <Card className="border-0 shadow-none"> <CardHeader className="text-center mb-2"> <CardTitle className="font-bold text-3xl text-[#6D2315]">INVOICE</CardTitle> </CardHeader> <CardContent> <form onSubmit={handleSubmit(onSubmit)} className="space-y-8"> {/* Basic Info */} <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div> <Label className="py-2 block text-sm text-gray-700">Invoice Number</Label> <Controller name="invoiceNumber" control={control} rules={{ required: "Invoice Number is required!", pattern: { value: /^\d{4}$/, message: "Invoice Number must be exactly 4 digits (0-9)", }, }} render={({ field }) => ( <Input {...field} placeholder={`Nomor invoice terakhir: ${lastInvoiceNumber || "0000"}`} maxLength={4} required /> )} /> {errors.invoiceNumber && ( <p role="alert" className="text-sm text-red-500"> {errors.invoiceNumber.message} </p> )} </div> <div> <Label className="py-2 block text-sm text-gray-700">Buyer Name</Label> <Controller name="buyerName" control={control} rules={{ required: "Buyer Name is required!", pattern: { value: /^[A-Za-z\s]+$/, message: "Buyer Name must contain only letters and spaces", }, }} render={({ field }) => <Input {...field} placeholder="Nama pembeli" required />} /> {errors.buyerName && ( <p role="alert" className="text-sm text-red-500"> {errors.buyerName.message} </p> )} </div> <div> <Label className="py-2 block text-sm text-gray-700">Invoice Date</Label> <DatePicker invoiceDate={invoiceDate} setInvoiceDate={setInvoiceDate} /> </div> </div> {/* Item List */} <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> {items.map((item, index) => ( <div key={index}> <h3 className="text-sm font-medium text-[#6D2315] mb-1">Item {index + 1}</h3> <div className="bg-[#fffefb] border border-[#fceee4] rounded-md p-4 space-y-3"> {/* Item Select */} <div> <Label className="text-sm text-gray-700 mb-1 block">Item</Label> <ProductCombobox products={products} value={item.productId} onChange={(val) => handleItemChange(index, "productId", val)} /> </div> {/* Size & Qty */} <div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <div> <Label className="text-sm text-gray-700 mb-1 block">Size</Label> <SizeCombobox sizes={sizes} value={item.sizePriceId} onChange={(val, price) => { handleItemChange(index, "sizePriceId", val); handleItemChange(index, "price", price); }} /> </div> <div> <Label className="text-sm text-gray-700 mb-1 block">Qty</Label> {/* desktop */} <div className="hidden md:block"> <Input type="number" value={item.quantity} onChange={(e) => handleItemChange(index, "quantity", e.target.value)} required /> </div> <div className="flex items-center gap-2 md:hidden"> <Input type="number" value={item.quantity} onChange={(e) => handleItemChange(index, "quantity", e.target.value)} className="w-15 text-center" /> <button type="button" className="w-15 px-2 py-1 border rounded bg-rose-500 text-white" onClick={() => handleItemChange( index, "quantity", Math.max(1, Number(item.quantity) - 1) ) } > - </button> <button type="button" className="w-15 px-2 py-1 border rounded bg-emerald-500 text-white" onClick={() => handleItemChange(index, "quantity", Number(item.quantity) + 1) } > + </button> </div> </div> </div> {/* Price & Total */} <div className="grid grid-cols-2 gap-2"> <div> <Label className="text-sm text-gray-700 mb-1 block">Price</Label> <Input type="number" value={item.price} disabled className="bg-gray-100" /> </div> <div> <Label className="text-sm text-gray-700 mb-1 block">Total</Label> <Input value={item.total.toLocaleString("id-ID")} disabled className="bg-gray-100" /> </div> </div> {/* Discount Each Item*/} <div> <Label className="text-sm text-gray-700 mb-1 block"> Discount (Optional) </Label> <div className="grid grid-cols-2 gap-2"> <div> <Label className="text-xs text-gray-500">Percent (%)</Label> <Input type="number" placeholder="%" min={0} max={100} step="any" value={ item.discountMode === "percent" ? item.discountInput : calculateDiscountPercent(item) } onChange={(e) => handleItemChange(index, "discountInput", e.target.value, "percent") } /> </div> <div> <Label className="text-xs text-gray-500">Amount (Rp)</Label> <Input type="number" placeholder="Rp" min={0} value={ item.discountMode === "amount" ? item.discountInput : item.discountAmount } onChange={(e) => handleItemChange(index, "discountInput", e.target.value, "amount") } /> </div> </div> </div> {/* Delete button */} <div className="flex justify-end"> <Button type="button" variant="destructive" onClick={() => removeItem(index)} className="h-9 px-3" > <Trash2 className="w-4 h-4" /> </Button> </div> </div> </div> ))} {/* Add Item Button */} <div className="md:col-span-3"> <Button type="button" onClick={addItem} className="mt-2 bg-[#6D2315] hover:bg-[#591c10] text-white" > + Add Item </Button> </div> </div> <div className="grid grid-cols-1 md:grid-cols-12 gap-4"> {/* Discount General */} <div className="md:col-span-4"> <div className="bg-[#fffaf0] border border-[#f4e3d3] rounded-md px-4 py-3 h-full"> <Label className="block text-sm text-gray-700 mb-2">Discount (Optional)</Label> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div> <Label className="text-xs text-gray-500 mb-1 block">Percent (%)</Label> <Input type="number" min={0} max={100} step="any" value={ discountMode === "percent" ? discountInput : discountPercent.toFixed(2) || 0 } onChange={(e) => { setDiscountMode("percent"); setDiscountInput(e.target.value); }} /> </div> <div> <Label className="text-xs text-gray-500 mb-1 block">Amount (Rp)</Label> <Input type="number" min={0} value={discountMode === "amount" ? discountInput : discountAmount} onChange={(e) => { setDiscountMode("amount"); setDiscountInput(e.target.value); }} /> </div> </div> </div> </div> <div className="md:col-span-8 hidden md:block"></div> {/* Subtotal */} <div className="md:col-span-4"> <Label className="py-2 block text-sm text-gray-700">Subtotal</Label> <Input value={subtotal.toLocaleString("id-ID")} disabled className="bg-gray-100" /> </div> {/* Shipping */} <div className="md:col-span-4"> <Label className="py-2 block text-sm text-gray-700">Shipping Price</Label> <Input type="number" value={shippingPrice} onChange={(e) => setShippingPrice(e.target.value)} /> </div> {/* Total */} <div className="md:col-span-4"> <Label className="py-2 block text-sm text-gray-700">Total Price</Label> <Input value={totalPrice.toLocaleString("id-ID")} disabled className="bg-gray-100" /> </div> </div> <Button type="submit" className="w-full bg-[#6D2315] hover:bg-[#591c10] text-white"> Create Invoice </Button> </form> </CardContent> </Card> </div> </section> ); } ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/UpdateInvoiceForm.jsx: -------------------------------------------------------------------------------- ```javascript "use client"; import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { Controller, useForm } from "react-hook-form"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import DatePicker from "@/components/dashboard/DatePicker"; import ProductCombobox from "./create/_components/ProductsCombobox"; import SizeCombobox from "./create/_components/SizeCombobox"; import StatusCombobox from "./_components/StatusCombobox"; import { getAllProducts } from "@/lib/actions/products/getAllProducts"; import { getAllSizePrice } from "@/lib/actions/size-price/getAll"; import { updateInvoice } from "@/lib/actions/invoice/updateInvoice"; import { calculateDiscountAmount, calculateDiscountPercent, getPageTitle } from "@/lib/utils"; import { ChevronRight, Trash2 } from "lucide-react"; import { toast } from "sonner"; export const metadata = { title: getPageTitle("Invoice Edit"), }; export default function UpdateInvoiceForm({ invoice }) { const router = useRouter(); const [products, setProducts] = useState([]); const [sizes, setSizes] = useState([]); const [invoiceDate, setInvoiceDate] = useState(invoice.invoiceDate?.split("T")[0] || ""); const [items, setItems] = useState([]); const [shippingPrice, setShippingPrice] = useState(invoice.shipping || 0); const [status, setStatus] = useState(invoice.status || "pending"); const [discountMode, setDiscountMode] = useState("amount"); const [discountInput, setDiscountInput] = useState(0); const { control, handleSubmit, formState: { errors }, } = useForm({ defaultValues: { invoiceNumber: invoice.invoiceNumber || "", buyerName: invoice.buyerName || "", }, mode: "onChange", }); useEffect(() => { const fetchData = async () => { const { data: productsData } = await getAllProducts(); const { data: sizeData } = await getAllSizePrice(); setProducts(productsData || []); setSizes(sizeData || []); }; fetchData(); }, []); useEffect(() => { if (invoice?.items?.length) { const mappedItems = invoice.items.map((item) => { const quantity = item.quantity || 0; const price = item.sizePrice?.price || 0; const subtotal = quantity * price; const discountAmount = item.discountAmount || 0; return { productId: item.productId, sizePriceId: item.sizePriceId, quantity, price, discountAmount, discountInput: String(discountAmount), discountMode: "amount", total: subtotal - discountAmount, }; }); setItems(mappedItems); } if (invoice?.discount !== undefined) { setDiscountInput(String(invoice.discount)); setDiscountMode("amount"); } }, [invoice]); const onUpdate = async (data) => { const isInvalid = items.some((item) => !item.productId || !item.sizePriceId); if (isInvalid) { toast.error("You must add product and size before submitting!"); return; } const result = await updateInvoice({ invoiceId: invoice.id, invoiceData: { invoiceNumber: data.invoiceNumber, buyerName: data.buyerName.trim().toLowerCase(), invoiceDate, totalPrice, discount: discountAmount, shipping: parseInt(shippingPrice), status, }, items, }); if (!result.success) { toast.error(result.error || "Failed to update invoice"); return; } toast.success("Invoice has been updated!"); router.push("/dashboard/invoices"); }; const handleItemChange = (index, field, value, mode = null) => { const updatedItems = [...items]; const item = updatedItems[index]; if (field === "sizePriceId") { const selectedSize = sizes.find((s) => s.id === value); item.sizePriceId = value; item.price = selectedSize?.price || 0; } else if (field === "quantity") { const parsed = parseInt(value, 10); item.quantity = isNaN(parsed) || parsed < 1 ? 1 : parsed; } else if (field === "price") { const parsed = parseInt(value, 10); item.price = isNaN(parsed) ? 0 : parsed; } else if (field === "discountMode") { item.discountMode = value; } else if (field === "discountInput") { item.discountInput = value; item.discountMode = mode; } else { item[field] = value; } const qty = item.quantity || 0; const price = item.price || 0; const rawTotal = qty * price; const discountAmount = calculateDiscountAmount({ quantity: item.quantity, price: item.price, discountInput: item.discountInput, discountMode: item.discountMode, }); item.discountAmount = discountAmount; item.total = rawTotal - discountAmount; setItems(updatedItems); }; const subtotal = items.reduce((sum, item) => sum + item.total, 0); const discountAmount = discountMode === "percent" ? Math.round(((parseFloat(discountInput) || 0) / 100) * subtotal) : parseInt(discountInput) || 0; const discountPercent = discountMode === "amount" ? ((parseInt(discountInput) || 0) / subtotal) * 100 : parseFloat(discountInput) || 0; const totalPrice = subtotal + (parseInt(shippingPrice) || 0) - discountAmount; const addItem = () => { setItems([ ...items, { productId: "", sizePriceId: "", price: 0, quantity: 1, discountAmount: 0, discountInput: "0", discountMode: "amount", total: 0, }, ]); }; const removeItem = (index) => { const updated = [...items]; updated.splice(index, 1); setItems(updated); }; return ( <section className="w-full px-4 py-6 bg-[#fffaf0]"> <div className="bg-white rounded-xl shadow-md p-6 space-y-6 border border-[#f4e3d3]"> {/* Breadcrumbs (Mobile Only) */} <div className="block md:hidden text-sm text-gray-500 mb-4"> <nav className="flex items-center space-x-1"> <Link className="text-gray-400" href="/dashboard/invoices"> List Invoice </Link> <ChevronRight className="w-4 h-4 text-gray-400" /> <span className="text-gray-700 font-medium">Edit Invoice</span> </nav> </div> <Card className="border-0 shadow-none"> <CardHeader className="text-center mb-2"> <CardTitle className="font-bold text-3xl text-[#6D2315]">EDIT INVOICE</CardTitle> </CardHeader> <CardContent> <form onSubmit={handleSubmit(onUpdate)} className="space-y-8"> {/* Basic Info */} <div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div> <Label className="py-2 block text-sm text-gray-700">Invoice Number</Label> <Controller name="invoiceNumber" control={control} rules={{ required: "Invoice Number is required!", pattern: { value: /^\d{4}$/, message: "Invoice Number must be exactly 4 digits (0-9)", }, }} render={({ field }) => <Input {...field} maxLength={4} required />} /> {errors.invoiceNumber && ( <p role="alert" className="text-sm text-red-500"> {errors.invoiceNumber.message} </p> )} </div> <div> <Label className="py-2 block text-sm text-gray-700">Buyer Name</Label> <Controller name="buyerName" control={control} rules={{ required: "Buyer Name is required!", pattern: { value: /^[A-Za-z\s]+$/, message: "Buyer Name must contain only letters and spaces", }, }} render={({ field }) => <Input {...field} placeholder="Nama pembeli" required />} /> {errors.buyerName && ( <p role="alert" className="text-sm text-red-500"> {errors.buyerName.message} </p> )} </div> <div> <Label className="py-2 block text-sm text-gray-700">Invoice Date</Label> <DatePicker invoiceDate={invoiceDate} setInvoiceDate={setInvoiceDate} /> </div> <div className=""> <Label className="py-2 block text-sm text-gray-700">Status</Label> <StatusCombobox value={status} onChange={setStatus} required /> </div> </div> {/* Items */} <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> {items.map((item, index) => ( <div key={index}> <h3 className="text-sm font-medium text-[#6D2315] mb-1">Item {index + 1}</h3> <div className="bg-[#fffefb] border border-[#fceee4] rounded-md p-4 space-y-3"> {/* Item Select */} <div> <Label className="text-sm text-gray-700 mb-1 block">Item</Label> <ProductCombobox products={products} value={item.productId} onChange={(val) => handleItemChange(index, "productId", val)} /> </div> {/* Size & Qty */} <div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <div> <Label className="text-sm text-gray-700 mb-1 block">Size</Label> <SizeCombobox sizes={sizes} value={item.sizePriceId} onChange={(val, price) => { handleItemChange(index, "sizePriceId", val); handleItemChange(index, "price", price); }} /> </div> <div> <Label className="text-sm text-gray-700 mb-1 block">Qty</Label> {/* desktop */} <div className="hidden md:block"> <Input type="number" value={item.quantity} onChange={(e) => handleItemChange(index, "quantity", e.target.value)} required /> </div> {/* mobile */} <div className="flex items-center gap-2 md:hidden"> <Input type="number" value={item.quantity} onChange={(e) => handleItemChange(index, "quantity", e.target.value)} className="w-15 text-center" /> <button type="button" className="w-15 px-2 py-1 border rounded bg-rose-500 text-white" onClick={() => handleItemChange( index, "quantity", Math.max(1, Number(item.quantity) - 1) ) } > - </button> <button type="button" className="w-15 px-2 py-1 border rounded bg-emerald-500 text-white" onClick={() => handleItemChange(index, "quantity", Number(item.quantity) + 1) } > + </button> </div> </div> </div> {/* Price & Total */} <div className="grid grid-cols-2 gap-2"> <div> <Label className="text-sm text-gray-700 mb-1 block">Price</Label> <Input type="number" value={item.price} disabled className="bg-gray-100" /> </div> <div> <Label className="text-sm text-gray-700 mb-1 block">Total</Label> <Input value={item.total.toLocaleString("id-ID")} disabled className="bg-gray-100" /> </div> </div> {/* Discount Each Item */} <div> <Label className="text-sm text-gray-700 mb-1 block"> Discount (Optional) </Label> <div className="grid grid-cols-2 gap-2"> <div> <Label className="text-xs text-gray-500">Percent (%)</Label> <Input type="number" placeholder="%" min={0} max={100} step="any" value={ item.discountMode === "percent" ? item.discountInput : calculateDiscountPercent(item) } onChange={(e) => handleItemChange(index, "discountInput", e.target.value, "percent") } /> </div> <div> <Label className="text-xs text-gray-500">Amount (Rp)</Label> <Input type="number" placeholder="Rp" min={0} value={ item.discountMode === "amount" ? item.discountInput : item.discountAmount } onChange={(e) => handleItemChange(index, "discountInput", e.target.value, "amount") } /> </div> </div> </div> {/* Delete button */} <div className="flex justify-end"> <Button type="button" variant="destructive" onClick={() => removeItem(index)} className="h-9 px-3" > <Trash2 className="w-4 h-4" /> </Button> </div> </div> </div> ))} {/* Add Item Button */} <div className="md:col-span-3"> <Button type="button" onClick={addItem} className="mt-2 bg-[#6D2315] hover:bg-[#591c10] text-white" > + Add Item </Button> </div> </div> {/* Shipping & Total */} <div className="grid grid-cols-1 md:grid-cols-12 gap-4"> {/* Discount General */} <div className="md:col-span-4"> <div className="bg-[#fffaf0] border border-[#f4e3d3] rounded-md px-4 py-3 h-full"> <Label className="block text-sm text-gray-700 mb-2">Discount (Optional)</Label> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div> <Label className="text-xs text-gray-500 mb-1 block">Percent (%)</Label> <Input type="number" min={0} max={100} step="any" value={ discountMode === "percent" ? discountInput : discountPercent.toFixed(2) || 0 } onChange={(e) => { setDiscountMode("percent"); setDiscountInput(e.target.value); }} /> </div> <div> <Label className="text-xs text-gray-500 mb-1 block">Amount (Rp)</Label> <Input type="number" min={0} value={discountMode === "amount" ? discountInput : discountAmount} onChange={(e) => { setDiscountMode("amount"); setDiscountInput(e.target.value); }} /> </div> </div> </div> </div> <div className="md:col-span-8 hidden md:block"></div> {/* Subtotal */} <div className="md:col-span-4"> <Label className="py-2 block text-sm text-gray-700">Subtotal</Label> <Input value={subtotal.toLocaleString("id-ID")} disabled className="bg-gray-100" /> </div> {/* Shipping */} <div className="md:col-span-4"> <Label className="py-2 block text-sm text-gray-700">Shipping Price</Label> <Input type="number" value={shippingPrice} onChange={(e) => setShippingPrice(e.target.value)} /> </div> {/* Total */} <div className="md:col-span-4"> <Label className="py-2 block text-sm text-gray-700">Total Price</Label> <Input value={totalPrice.toLocaleString("id-ID")} disabled className="bg-gray-100" /> </div> </div> <Button type="submit" className="w-full bg-[#6D2315] hover:bg-[#591c10] text-white"> Save </Button> </form> </CardContent> </Card> </div> </section> ); } ```