This is page 1 of 2. Use http://codebase.md/ralfarishi/cheese-stick-koe-dashboard?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitignore ├── components.json ├── eslint.config.mjs ├── jsconfig.json ├── middleware.js ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public │ ├── file.svg │ ├── globe.svg │ ├── logo.png │ ├── next.svg │ ├── qris.png │ ├── vercel.svg │ └── window.svg ├── README.md └── src ├── app │ ├── _components │ │ └── LoginForm.jsx │ ├── api │ │ └── logout │ │ └── route.js │ ├── dashboard │ │ ├── invoices │ │ │ ├── _components │ │ │ │ ├── DeleteInvoiceModal.jsx │ │ │ │ ├── InvoiceDownloadModal.jsx │ │ │ │ ├── InvoicePreview.jsx │ │ │ │ └── StatusCombobox.jsx │ │ │ ├── [invoiceNumber] │ │ │ │ └── page.jsx │ │ │ ├── create │ │ │ │ ├── _components │ │ │ │ │ ├── ProductsCombobox.jsx │ │ │ │ │ └── SizeCombobox.jsx │ │ │ │ ├── CreateInvoicePage.jsx │ │ │ │ └── page.js │ │ │ ├── InvoicePage.jsx │ │ │ ├── page.js │ │ │ ├── Table.jsx │ │ │ └── UpdateInvoiceForm.jsx │ │ ├── layout.jsx │ │ ├── page.jsx │ │ ├── products │ │ │ ├── _components │ │ │ │ ├── ProductDeleteModal.jsx │ │ │ │ ├── ProductEditModal.jsx │ │ │ │ ├── ProductModal.jsx │ │ │ │ ├── ProductModalButton.jsx │ │ │ │ └── ProductTable.jsx │ │ │ ├── page.js │ │ │ └── ProductPage.jsx │ │ └── size-pricing │ │ ├── _components │ │ │ ├── AddSizeButton.jsx │ │ │ ├── AddSizeModal.jsx │ │ │ ├── DeleteSizeModal.jsx │ │ │ ├── EditSizeModal.jsx │ │ │ └── Table.jsx │ │ ├── page.js │ │ └── SizePage.jsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.js │ ├── not-found.js │ ├── page.js │ └── unauthorized.js ├── components │ ├── dashboard │ │ ├── DatePicker.jsx │ │ ├── Modal.jsx │ │ └── Sidebar.jsx │ └── ui │ ├── badge.jsx │ ├── button.jsx │ ├── calendar.jsx │ ├── card.jsx │ ├── command.jsx │ ├── dialog.jsx │ ├── drawer.jsx │ ├── input.jsx │ ├── label.jsx │ ├── popover.jsx │ ├── radio-group.jsx │ ├── select.jsx │ └── sonner.jsx └── lib ├── actions │ ├── invoice │ │ ├── deleteInvoice.js │ │ ├── getAll.js │ │ ├── getInvoiceByNumber.js │ │ ├── getInvoiceWithItem.js │ │ ├── submitInvoice.js │ │ └── updateInvoice.js │ ├── products │ │ ├── addProduct.js │ │ ├── deleteProduct.js │ │ ├── getAllProducts.js │ │ └── updateProduct.js │ └── size-price │ ├── addSize.js │ ├── deleteSize.js │ ├── getAll.js │ └── updateSize.js ├── exportToPng.js ├── supabaseBrowser.js ├── supabaseServer.js ├── utils.js └── verifySession.js ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | /src/generated/prisma 44 | 45 | prisma 46 | 47 | db_backups ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | Tech stack: 2 | 3 | - Next.js 4 | - shadcn + tailwind 5 | - supabase + prisma 6 | 7 | Please visit [Cheese Stick Koe](https://cheesestick-koe.my.id). Thanks ♥️ 8 | ``` -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | } 7 | } 8 | ``` -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- ``` 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | ``` -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- ``` 1 | <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: -------------------------------------------------------------------------------- ``` 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | authInterrupts: true, 5 | }, 6 | }; 7 | 8 | export default nextConfig; 9 | ``` -------------------------------------------------------------------------------- /src/lib/supabaseBrowser.js: -------------------------------------------------------------------------------- ```javascript 1 | import { createBrowserClient } from "@supabase/ssr"; 2 | 3 | export function supabaseBrowser() { 4 | return createBrowserClient( 5 | process.env.NEXT_PUBLIC_SUPABASE_URL, 6 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY 7 | ); 8 | } 9 | ``` -------------------------------------------------------------------------------- /src/lib/verifySession.js: -------------------------------------------------------------------------------- ```javascript 1 | import { supabaseServer } from "@/lib/supabaseServer"; 2 | 3 | export async function verifySession() { 4 | const supabase = await supabaseServer(); 5 | const { 6 | data: { session }, 7 | } = await supabase.auth.getSession(); 8 | 9 | return session; 10 | } 11 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/page.js: -------------------------------------------------------------------------------- ```javascript 1 | import { unauthorized } from "next/navigation"; 2 | 3 | import { verifySession } from "@/lib/verifySession"; 4 | 5 | import InvoicePage from "./InvoicePage"; 6 | 7 | export default async function InvoicesPage() { 8 | const session = await verifySession(); 9 | 10 | if (!session) { 11 | unauthorized(); 12 | } 13 | 14 | return <InvoicePage />; 15 | } 16 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/create/page.js: -------------------------------------------------------------------------------- ```javascript 1 | import { unauthorized } from "next/navigation"; 2 | 3 | import { verifySession } from "@/lib/verifySession"; 4 | 5 | import CreateInvoicePage from "./CreateInvoicePage"; 6 | 7 | export default async function Page() { 8 | const session = await verifySession(); 9 | 10 | if (!session) { 11 | unauthorized(); 12 | } 13 | 14 | return <CreateInvoicePage />; 15 | } 16 | ``` -------------------------------------------------------------------------------- /src/lib/actions/size-price/addSize.js: -------------------------------------------------------------------------------- ```javascript 1 | "use server"; 2 | 3 | import { supabaseServer } from "@/lib/supabaseServer"; 4 | 5 | export async function addSize({ size, price }) { 6 | const supabase = await supabaseServer(); 7 | const { data, error } = await supabase 8 | .from("ProductSizePrice") 9 | .insert([{ size, price }]) 10 | .select() 11 | .single(); 12 | return { data, error }; 13 | } 14 | ``` -------------------------------------------------------------------------------- /src/app/api/logout/route.js: -------------------------------------------------------------------------------- ```javascript 1 | import { cookies } from "next/headers"; 2 | import { NextResponse } from "next/server"; 3 | 4 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; 5 | 6 | export async function POST() { 7 | const supabase = createRouteHandlerClient({ cookies }); 8 | 9 | await supabase.auth.signOut(); 10 | 11 | return NextResponse.json({ message: "Logged out" }); 12 | } 13 | ``` -------------------------------------------------------------------------------- /src/lib/actions/products/addProduct.js: -------------------------------------------------------------------------------- ```javascript 1 | "use server"; 2 | 3 | import { supabaseServer } from "@/lib/supabaseServer"; 4 | 5 | export async function addProduct({ name, description }) { 6 | const supabase = await supabaseServer(); 7 | const { data, error } = await supabase 8 | .from("Product") 9 | .insert([{ name, description: description || null }]) 10 | .select() 11 | .single(); 12 | return { data, error }; 13 | } 14 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/layout.jsx: -------------------------------------------------------------------------------- ```javascript 1 | import { Sidebar } from "@/components/dashboard/Sidebar"; 2 | 3 | export default function DashboardLayout({ children }) { 4 | return ( 5 | <div className="min-h-screen flex flex-col md:flex-row bg-[#fffaf0]"> 6 | <Sidebar /> 7 | <main className="flex-1 p-4 md:p-6 bg-white rounded-tl-xl md:rounded-tl-none shadow-inner"> 8 | {children} 9 | </main> 10 | </div> 11 | ); 12 | } 13 | ``` -------------------------------------------------------------------------------- /src/lib/actions/products/getAllProducts.js: -------------------------------------------------------------------------------- ```javascript 1 | "use server"; 2 | 3 | import { supabaseServer } from "@/lib/supabaseServer"; 4 | 5 | export async function getAllProducts(sortOrder = "asc") { 6 | const supabase = await supabaseServer(); 7 | 8 | const { data, error } = await supabase 9 | .from("Product") 10 | .select("id, name, description, createdAt") 11 | .order("name", { ascending: sortOrder === "asc" }); 12 | 13 | return { data, error }; 14 | } 15 | ``` -------------------------------------------------------------------------------- /src/lib/actions/size-price/getAll.js: -------------------------------------------------------------------------------- ```javascript 1 | "use server"; 2 | 3 | import { supabaseServer } from "@/lib/supabaseServer"; 4 | 5 | export async function getAllSizePrice(sortOrder = "asc") { 6 | const supabase = await supabaseServer(); 7 | 8 | const { data, error } = await supabase 9 | .from("ProductSizePrice") 10 | .select("id, size, price, createdAt") 11 | .order("size", { ascending: sortOrder === "asc" }); 12 | 13 | return { data, error }; 14 | } 15 | ``` -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- ``` 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [...compat.extends("next/core-web-vitals")]; 13 | 14 | export default eslintConfig; 15 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/size-pricing/page.js: -------------------------------------------------------------------------------- ```javascript 1 | import { unauthorized } from "next/navigation"; 2 | 3 | import { getPageTitle } from "@/lib/utils"; 4 | import { verifySession } from "@/lib/verifySession"; 5 | 6 | import SizePage from "./SizePage"; 7 | 8 | export const metadata = { 9 | title: getPageTitle("Size"), 10 | }; 11 | 12 | export default function page() { 13 | const session = verifySession(); 14 | 15 | if (!session) { 16 | unauthorized(); 17 | } 18 | 19 | return <SizePage />; 20 | } 21 | ``` -------------------------------------------------------------------------------- /src/app/page.js: -------------------------------------------------------------------------------- ```javascript 1 | import { redirect } from "next/navigation"; 2 | 3 | import { supabaseServer } from "@/lib/supabaseServer"; 4 | 5 | import LoginForm from "./_components/LoginForm"; 6 | 7 | export default async function LoginPage() { 8 | const supabase = await supabaseServer(); 9 | 10 | const { 11 | data: { session }, 12 | } = await supabase.auth.getSession(); 13 | 14 | if (session) { 15 | redirect("/dashboard"); 16 | } 17 | 18 | return <LoginForm />; 19 | } 20 | ``` -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- ``` 1 | <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 1 | import { unauthorized } from "next/navigation"; 2 | 3 | import { getPageTitle } from "@/lib/utils"; 4 | import { verifySession } from "@/lib/verifySession"; 5 | 6 | import ProductPage from "./ProductPage"; 7 | 8 | export const metadata = { 9 | title: getPageTitle("Products"), 10 | }; 11 | 12 | export default function Page() { 13 | const session = verifySession(); 14 | 15 | if (!session) { 16 | unauthorized(); 17 | } 18 | 19 | return <ProductPage />; 20 | } 21 | ``` -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- ``` 1 | <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 1 | "use server"; 2 | 3 | import { supabaseServer } from "@/lib/supabaseServer"; 4 | 5 | export async function getAllInvoice(sortOrder = "asc") { 6 | const supabase = await supabaseServer(); 7 | 8 | const { data, error } = await supabase 9 | .from("Invoice") 10 | .select("id, invoiceNumber, buyerName, totalPrice, invoiceDate, status, createdAt") 11 | .order("invoiceNumber", { ascending: sortOrder === "asc" }); 12 | 13 | return { data, error }; 14 | } 15 | ``` -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": false, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } ``` -------------------------------------------------------------------------------- /src/app/not-found.js: -------------------------------------------------------------------------------- ```javascript 1 | export default function NotFound() { 2 | return ( 3 | <main className="grid min-h-screen place-items-center bg-white px-6 py-24 sm:py-32 lg:px-8"> 4 | <div className="text-center"> 5 | <p className="text-9xl font-semibold text-[#6d2315]">404</p> 6 | <h1 className="mt-4 text-2xl font-bold tracking-tight text-gray-500 sm:text-5xl"> 7 | Not Found 8 | </h1> 9 | <p className="mt-6 text-lg leading-8 text-gray-400">Could not find requested resource</p> 10 | </div> 11 | </main> 12 | ); 13 | } 14 | ``` -------------------------------------------------------------------------------- /src/lib/actions/invoice/getInvoiceWithItem.js: -------------------------------------------------------------------------------- ```javascript 1 | import { supabaseBrowser } from "@/lib/supabaseBrowser"; 2 | 3 | export async function getInvoiceWithItems(invoiceId) { 4 | const supabase = supabaseBrowser(); 5 | 6 | const { data: invoice, error: err1 } = await supabase 7 | .from("Invoice") 8 | .select("*") 9 | .eq("id", invoiceId) 10 | .single(); 11 | 12 | const { data: items, error: err2 } = await supabase 13 | .from("InvoiceItem") 14 | .select("*, product:Product(name), size:ProductSizePrice(size)") 15 | .eq("invoiceId", invoiceId); 16 | 17 | return { invoice, items }; 18 | } 19 | ``` -------------------------------------------------------------------------------- /src/components/ui/sonner.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner } from "sonner"; 5 | 6 | const Toaster = ({ 7 | ...props 8 | }) => { 9 | const { theme = "system" } = useTheme() 10 | 11 | return ( 12 | <Sonner 13 | theme={theme} 14 | className="toaster group" 15 | style={ 16 | { 17 | "--normal-bg": "var(--popover)", 18 | "--normal-text": "var(--popover-foreground)", 19 | "--normal-border": "var(--border)" 20 | } 21 | } 22 | {...props} /> 23 | ); 24 | } 25 | 26 | export { Toaster } 27 | ``` -------------------------------------------------------------------------------- /src/app/unauthorized.js: -------------------------------------------------------------------------------- ```javascript 1 | export default function UnauthorizedPage() { 2 | return ( 3 | <main className="grid min-h-screen place-items-center bg-white px-6 py-24 sm:py-32 lg:px-8"> 4 | <div className="text-center"> 5 | <p className="text-9xl font-semibold text-[#6d2315]">401</p> 6 | <h1 className="mt-4 text-2xl font-bold tracking-tight text-gray-500 sm:text-5xl"> 7 | Unauthorized Access 8 | </h1> 9 | <p className="mt-6 text-lg leading-8 text-gray-400"> 10 | Sorry, you don't have permission to access this page. account. 11 | </p> 12 | </div> 13 | </main> 14 | ); 15 | } 16 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/size-pricing/_components/AddSizeButton.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | import AddSizeModal from "./AddSizeModal"; 7 | 8 | export default function AddSizeButton({ onSizeAdded }) { 9 | const [open, setOpen] = useState(false); 10 | 11 | return ( 12 | <> 13 | <Button 14 | onClick={() => setOpen(true)} 15 | className="bg-[#6D2315] hover:bg-[#591c10] text-white px-4 py-2 rounded-md" 16 | > 17 | Add Size 18 | </Button> 19 | <AddSizeModal 20 | open={open} 21 | setOpen={setOpen} 22 | onSuccess={() => { 23 | onSizeAdded?.(); 24 | }} 25 | /> 26 | </> 27 | ); 28 | } 29 | ``` -------------------------------------------------------------------------------- /src/lib/actions/invoice/getInvoiceByNumber.js: -------------------------------------------------------------------------------- ```javascript 1 | import { supabaseServer } from "@/lib/supabaseServer"; 2 | 3 | export const getInvoiceByNumber = async (invoiceNumber) => { 4 | const supabase = await supabaseServer(); 5 | 6 | const { data: invoice, error } = await supabase 7 | .from("Invoice") 8 | .select( 9 | ` 10 | *, 11 | items:InvoiceItem( 12 | id, 13 | productId, 14 | sizePriceId, 15 | quantity, 16 | discountAmount, 17 | product:Product(id, name), 18 | sizePrice:ProductSizePrice(id, price, size) 19 | ) 20 | ` 21 | ) 22 | .eq("invoiceNumber", invoiceNumber) 23 | .single(); 24 | 25 | if (error) return { error }; 26 | return { data: invoice }; 27 | }; 28 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/products/_components/ProductModalButton.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | 7 | import ProductModal from "./ProductModal"; 8 | 9 | export default function ProductModalButton({ onProductAdded }) { 10 | const [open, setOpen] = useState(false); 11 | 12 | return ( 13 | <> 14 | <Button 15 | onClick={() => setOpen(true)} 16 | className="bg-[#6D2315] hover:bg-[#591c10] text-white px-4 py-2 rounded-md" 17 | > 18 | Add Product 19 | </Button> 20 | <ProductModal 21 | open={open} 22 | setOpen={setOpen} 23 | onSuccess={() => { 24 | onProductAdded?.(); 25 | }} 26 | /> 27 | </> 28 | ); 29 | } 30 | ``` -------------------------------------------------------------------------------- /src/components/ui/label.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }) { 12 | return ( 13 | <LabelPrimitive.Root 14 | data-slot="label" 15 | className={cn( 16 | "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", 17 | className 18 | )} 19 | {...props} /> 20 | ); 21 | } 22 | 23 | export { Label } 24 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/size-pricing/SizePage.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useRef } from "react"; 4 | 5 | import SizePriceTable from "./_components/Table"; 6 | import AddSizeButton from "./_components/AddSizeButton"; 7 | 8 | export default function SizePage() { 9 | const tableRef = useRef(); 10 | 11 | return ( 12 | <section className="p-4 space-y-4"> 13 | <div className="flex justify-between items-center mb-2"> 14 | <h1 className="text-2xl font-bold text-[#6D2315] tracking-tight">Size & Price List</h1> 15 | <AddSizeButton 16 | onSizeAdded={() => { 17 | tableRef.current.refetch(); 18 | }} 19 | /> 20 | </div> 21 | 22 | <SizePriceTable ref={tableRef} /> 23 | </section> 24 | ); 25 | } 26 | ``` -------------------------------------------------------------------------------- /src/lib/supabaseServer.js: -------------------------------------------------------------------------------- ```javascript 1 | import { createServerClient } from "@supabase/ssr"; 2 | import { cookies } from "next/headers"; 3 | 4 | export const supabaseServer = async () => { 5 | const cookieStore = await cookies(); 6 | 7 | return createServerClient( 8 | process.env.NEXT_PUBLIC_SUPABASE_URL, 9 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, 10 | { 11 | cookies: { 12 | getAll: async () => cookieStore.getAll(), 13 | setAll: async (cookiesToSet) => { 14 | cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options)); 15 | }, 16 | remove: async (name, options) => cookieStore.delete(name, options), 17 | }, 18 | } 19 | ); 20 | }; 21 | ``` -------------------------------------------------------------------------------- /src/lib/exportToPng.js: -------------------------------------------------------------------------------- ```javascript 1 | import html2canvas from "html2canvas-pro"; 2 | 3 | export async function exportInvoiceToPng(element, filename = "Invoice.png") { 4 | if (!element) return; 5 | 6 | const width = element.scrollWidth; 7 | const height = element.scrollHeight; 8 | 9 | const canvas = await html2canvas(element, { 10 | scale: 2, 11 | scrollX: 0, 12 | scrollY: 0, 13 | width, 14 | height, 15 | windowWidth: width, 16 | windowHeight: height, 17 | backgroundColor: "#ffffff", 18 | }); 19 | 20 | const link = document.createElement("a"); 21 | link.download = filename; 22 | link.href = canvas.toDataURL("image/png"); 23 | 24 | return new Promise((resolve) => { 25 | link.click(); 26 | resolve(); 27 | }); 28 | } 29 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/products/ProductPage.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useRef } from "react"; 4 | 5 | import ProductModalButton from "./_components/ProductModalButton"; 6 | import ProductTable from "./_components/ProductTable"; 7 | 8 | export default function ProductPage() { 9 | const tableRef = useRef(); 10 | 11 | return ( 12 | <section className="p-4 space-y-4"> 13 | <div className="flex justify-between items-center mb-2"> 14 | <h1 className="text-2xl font-bold text-[#6D2315] tracking-tight">Product List</h1> 15 | <ProductModalButton 16 | onProductAdded={() => { 17 | tableRef.current?.refetch(); 18 | }} 19 | /> 20 | </div> 21 | 22 | <ProductTable ref={tableRef} /> 23 | </section> 24 | ); 25 | } 26 | ``` -------------------------------------------------------------------------------- /src/lib/actions/size-price/deleteSize.js: -------------------------------------------------------------------------------- ```javascript 1 | "use server"; 2 | 3 | import { supabaseServer } from "@/lib/supabaseServer"; 4 | import { revalidatePath } from "next/cache"; 5 | 6 | export async function deleteSize(sizeId) { 7 | const supabase = await supabaseServer(); 8 | 9 | try { 10 | const { error } = await supabase.from("ProductSizePrice").delete().match({ id: sizeId }); 11 | 12 | if (error) { 13 | console.error("❌ Supabase delete error:", error); 14 | return { success: false, message: "Failed to delete size" }; 15 | } 16 | 17 | revalidatePath("/dashboard/size-pricing"); 18 | 19 | return { success: true }; 20 | } catch (err) { 21 | console.log(err); 22 | return { success: false, message: "Failed to delete size" }; 23 | } 24 | } 25 | ``` -------------------------------------------------------------------------------- /src/lib/actions/invoice/deleteInvoice.js: -------------------------------------------------------------------------------- ```javascript 1 | "use server"; 2 | 3 | import { supabaseServer } from "@/lib/supabaseServer"; 4 | import { revalidatePath } from "next/cache"; 5 | 6 | export async function deleteInvoice(invoiceId) { 7 | const supabase = await supabaseServer(); 8 | 9 | try { 10 | const { error } = await supabase.from("Invoice").delete().match({ id: invoiceId }); 11 | 12 | if (error) { 13 | console.error("❌ Supabase delete error:", error); 14 | return { success: false, message: "Failed to delete invoice" }; 15 | } 16 | 17 | revalidatePath("/dashboard/invoices"); 18 | 19 | return { success: true }; 20 | } catch (err) { 21 | console.log(err); 22 | return { success: false, message: "Failed to delete invoice" }; 23 | } 24 | } 25 | ``` -------------------------------------------------------------------------------- /src/lib/actions/products/deleteProduct.js: -------------------------------------------------------------------------------- ```javascript 1 | "use server"; 2 | 3 | import { supabaseServer } from "@/lib/supabaseServer"; 4 | import { revalidatePath } from "next/cache"; 5 | 6 | export async function deleteProduct(productId) { 7 | const supabase = await supabaseServer(); 8 | 9 | try { 10 | const { error } = await supabase.from("Product").delete().match({ id: productId }); 11 | 12 | if (error) { 13 | console.error("❌ Supabase delete error:", error); 14 | return { success: false, message: "Failed to delete product" }; 15 | } 16 | 17 | revalidatePath("/dashboard/products"); 18 | 19 | return { success: true }; 20 | } catch (err) { 21 | console.log(err); 22 | return { success: false, message: "Failed to delete product" }; 23 | } 24 | } 25 | ``` -------------------------------------------------------------------------------- /src/lib/actions/size-price/updateSize.js: -------------------------------------------------------------------------------- ```javascript 1 | "use server"; 2 | 3 | import { supabaseServer } from "@/lib/supabaseServer"; 4 | import { revalidatePath } from "next/cache"; 5 | 6 | export async function updateSize(id, { size, price }) { 7 | const supabase = await supabaseServer(); 8 | 9 | try { 10 | const { error } = await supabase.from("ProductSizePrice").update({ size, price }).eq("id", id); 11 | 12 | if (error) { 13 | console.error("❌ Supabase delete error:", error); 14 | return { success: false, message: "Failed to update size" }; 15 | } 16 | 17 | revalidatePath("/dashboard/size-pricing"); 18 | 19 | return { success: true }; 20 | } catch (err) { 21 | console.log(err); 22 | return { success: false, message: "Failed to update size" }; 23 | } 24 | } 25 | ``` -------------------------------------------------------------------------------- /src/lib/actions/products/updateProduct.js: -------------------------------------------------------------------------------- ```javascript 1 | "use server"; 2 | 3 | import { supabaseServer } from "@/lib/supabaseServer"; 4 | import { revalidatePath } from "next/cache"; 5 | 6 | export async function updateProduct(id, { name, description }) { 7 | const supabase = await supabaseServer(); 8 | 9 | try { 10 | const { error } = await supabase.from("Product").update({ name, description }).eq("id", id); 11 | 12 | if (error) { 13 | console.error("❌ Supabase delete error:", error); 14 | return { success: false, message: "Failed to update product" }; 15 | } 16 | 17 | revalidatePath("/dashboard/products"); 18 | 19 | return { success: true }; 20 | } catch (err) { 21 | console.log(err); 22 | return { success: false, message: "Failed to update product" }; 23 | } 24 | } 25 | ``` -------------------------------------------------------------------------------- /src/app/layout.js: -------------------------------------------------------------------------------- ```javascript 1 | import { Geist, Geist_Mono } from "next/font/google"; 2 | import "./globals.css"; 3 | 4 | import { Toaster } from "sonner"; 5 | 6 | const geistSans = Geist({ 7 | variable: "--font-geist-sans", 8 | subsets: ["latin"], 9 | }); 10 | 11 | const geistMono = Geist_Mono({ 12 | variable: "--font-geist-mono", 13 | subsets: ["latin"], 14 | }); 15 | 16 | export const metadata = { 17 | title: "Cheese Stick Koe", 18 | description: "Invoice generator for Cheese Stick Koe company", 19 | }; 20 | 21 | export default function RootLayout({ children }) { 22 | return ( 23 | <html lang="en"> 24 | <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}> 25 | {children} 26 | <Toaster position="top-center" richColors /> 27 | </body> 28 | </html> 29 | ); 30 | } 31 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/InvoicePage.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useRef } from "react"; 4 | import Link from "next/link"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | 8 | import InvoicesTable from "./Table"; 9 | 10 | export default function InvoicePage() { 11 | const tableRef = useRef(); 12 | 13 | return ( 14 | <section className="p-4 space-y-4"> 15 | <div className="flex justify-between items-center mb-2"> 16 | <h1 className="text-2xl font-bold text-[#6D2315] tracking-tight">Invoices List</h1> 17 | <Button asChild className="bg-[#6D2315] hover:bg-[#591c10] text-white px-4 py-2 rounded-md"> 18 | <Link href="/dashboard/invoices/create">Create Invoice</Link> 19 | </Button> 20 | </div> 21 | 22 | <InvoicesTable ref={tableRef} /> 23 | </section> 24 | ); 25 | } 26 | ``` -------------------------------------------------------------------------------- /middleware.js: -------------------------------------------------------------------------------- ```javascript 1 | import { createMiddlewareClient } from "@supabase/auth-helpers-nextjs"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export async function middleware(req) { 5 | const res = NextResponse.next(); 6 | const supabase = createMiddlewareClient({ req, res }); 7 | 8 | const { 9 | data: { session }, 10 | } = await supabase.auth.getSession(); 11 | 12 | const { pathname } = req.nextUrl; 13 | 14 | if (session && pathname === "/") { 15 | return NextResponse.redirect(new URL("/dashboard", req.url)); 16 | } 17 | 18 | // trying to access protected route 19 | if (!session && pathname.startsWith("/dashboard")) { 20 | return NextResponse.redirect(new URL("/", req.url)); 21 | } 22 | 23 | return res; 24 | } 25 | 26 | export const config = { 27 | matcher: ["/", "/dashboard/:path*"], 28 | }; 29 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/[invoiceNumber]/page.jsx: -------------------------------------------------------------------------------- ```javascript 1 | import { unauthorized } from "next/navigation"; 2 | import { verifySession } from "@/lib/verifySession"; 3 | 4 | import UpdateInvoiceForm from "../UpdateInvoiceForm"; 5 | 6 | import { getInvoiceByNumber } from "@/lib/actions/invoice/getInvoiceByNumber"; 7 | 8 | export default async function UpdateInvoicePage(props) { 9 | const session = await verifySession(); 10 | 11 | if (!session) { 12 | unauthorized(); 13 | } 14 | 15 | const { invoiceNumber } = await props.params; 16 | 17 | if (!invoiceNumber) return <div className="text-red-500">Invoice number not found</div>; 18 | 19 | const { data: invoice, error } = await getInvoiceByNumber(invoiceNumber); 20 | 21 | if (error || !invoice) { 22 | return <div className="text-red-500">Invoice not found</div>; 23 | } 24 | 25 | return <UpdateInvoiceForm invoice={invoice} />; 26 | } 27 | ``` -------------------------------------------------------------------------------- /src/components/ui/input.jsx: -------------------------------------------------------------------------------- ```javascript 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ 6 | className, 7 | type, 8 | ...props 9 | }) { 10 | return ( 11 | <input 12 | type={type} 13 | data-slot="input" 14 | className={cn( 15 | "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", 16 | "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", 17 | "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 18 | className 19 | )} 20 | {...props} /> 21 | ); 22 | } 23 | 24 | export { Input } 25 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/size-pricing/_components/DeleteSizeModal.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { toast } from "sonner"; 4 | 5 | import { DialogFooter } from "@/components/ui/dialog"; 6 | import { Button } from "@/components/ui/button"; 7 | 8 | import Modal from "@/components/dashboard/Modal"; 9 | 10 | import { deleteSize } from "@/lib/actions/size-price/deleteSize"; 11 | 12 | export default function DeleteSizeModal({ open, onOpenChange, sizeId, onSuccess }) { 13 | const handleDelete = async () => { 14 | const result = await deleteSize(sizeId); 15 | 16 | if (result?.success) { 17 | toast.success("Size has been deleted"); 18 | onSuccess?.(); 19 | onOpenChange(false); 20 | } else { 21 | toast.error(result?.message || "Failed to delete size"); 22 | } 23 | }; 24 | 25 | return ( 26 | <Modal 27 | open={open} 28 | onOpenChange={onOpenChange} 29 | title="Delete Size" 30 | color="red" 31 | submitLabel="Delete" 32 | showCancel={false} 33 | > 34 | <p>Are you sure want to delete this size?</p> 35 | <DialogFooter> 36 | <Button 37 | variant="destructive" 38 | onClick={handleDelete} 39 | className={"bg-rose-600 hover:bg-red-600 w-24"} 40 | > 41 | Delete 42 | </Button> 43 | </DialogFooter> 44 | </Modal> 45 | ); 46 | } 47 | ``` -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- ``` 1 | <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 1 | "use client"; 2 | 3 | import { DialogFooter } from "@/components/ui/dialog"; 4 | import { Button } from "@/components/ui/button"; 5 | 6 | import Modal from "@/components/dashboard/Modal"; 7 | 8 | import { deleteInvoice } from "@/lib/actions/invoice/deleteInvoice"; 9 | 10 | import { toast } from "sonner"; 11 | 12 | export default function DeleteInvoiceModal({ open, onOpenChange, invoiceId, onSuccess }) { 13 | const handleDelete = async () => { 14 | if (!invoiceId) { 15 | toast.error("Invoice ID not found"); 16 | return; 17 | } 18 | 19 | const result = await deleteInvoice(invoiceId); 20 | 21 | if (result?.success) { 22 | toast.success("Invoice has been deleted"); 23 | onSuccess?.(); 24 | onOpenChange(false); 25 | } else { 26 | toast.error(result?.message || "Failed to delete invoice"); 27 | } 28 | }; 29 | 30 | return ( 31 | <Modal 32 | open={open} 33 | onOpenChange={onOpenChange} 34 | title="Delete Invoice" 35 | color="red" 36 | submitLabel="Delete" 37 | showCancel={false} 38 | > 39 | <p>Are you sure want to delete this invoice?</p> 40 | <DialogFooter> 41 | <Button 42 | variant="destructive" 43 | onClick={handleDelete} 44 | className={"bg-rose-600 hover:bg-red-600 w-24"} 45 | > 46 | Delete 47 | </Button> 48 | </DialogFooter> 49 | </Modal> 50 | ); 51 | } 52 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/products/_components/ProductModal.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | import Modal from "@/components/dashboard/Modal"; 6 | 7 | import { addProduct } from "@/lib/actions/products/addProduct"; 8 | 9 | import { toast } from "sonner"; 10 | 11 | export default function ProductModal({ open, setOpen, onSuccess }) { 12 | const [name, setName] = useState(""); 13 | const [description, setDescription] = useState(""); 14 | 15 | const handleSubmit = async (e) => { 16 | e.preventDefault(); 17 | 18 | const { data, error } = await addProduct({ name, description }); 19 | 20 | if (error && error.message) { 21 | toast.error("Failed to add product: " + error.message); 22 | } else { 23 | toast.success("Product has been added"); 24 | onSuccess?.(data); 25 | setOpen(false); 26 | setName(""); 27 | setDescription(""); 28 | } 29 | }; 30 | 31 | return ( 32 | <Modal 33 | open={open} 34 | onOpenChange={setOpen} 35 | title="Add Product" 36 | color="default" 37 | fields={[ 38 | { 39 | label: "Product Name", 40 | value: name, 41 | onChange: (e) => setName(e.target.value), 42 | required: true, 43 | }, 44 | { 45 | label: "Description", 46 | value: description, 47 | onChange: (e) => setDescription(e.target.value), 48 | }, 49 | ]} 50 | onSubmit={handleSubmit} 51 | submitLabel="Add" 52 | showCancel={false} 53 | buttonStyling="bg-primary" 54 | /> 55 | ); 56 | } 57 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "invoice-generator", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "seed": "node prisma/seed.js" 11 | }, 12 | "dependencies": { 13 | "@prisma/client": "^6.11.1", 14 | "@radix-ui/react-dialog": "^1.1.14", 15 | "@radix-ui/react-label": "^2.1.7", 16 | "@radix-ui/react-popover": "^1.1.14", 17 | "@radix-ui/react-radio-group": "^1.3.8", 18 | "@radix-ui/react-select": "^2.2.5", 19 | "@radix-ui/react-slot": "^1.2.3", 20 | "@supabase/auth-helpers-nextjs": "^0.10.0", 21 | "@supabase/ssr": "^0.6.1", 22 | "class-variance-authority": "^0.7.1", 23 | "clsx": "^2.1.1", 24 | "cmdk": "^1.1.1", 25 | "date-fns": "^4.1.0", 26 | "dotenv": "^17.2.0", 27 | "html2canvas-pro": "^1.5.11", 28 | "lucide-react": "^0.525.0", 29 | "next": "15.4.1", 30 | "next-themes": "^0.4.6", 31 | "prisma": "^6.11.1", 32 | "react": "19.1.0", 33 | "react-day-picker": "^9.8.0", 34 | "react-dom": "19.1.0", 35 | "react-hook-form": "^7.62.0", 36 | "sonner": "^2.0.6", 37 | "tailwind-merge": "^3.3.1", 38 | "vaul": "^1.1.2" 39 | }, 40 | "devDependencies": { 41 | "@eslint/eslintrc": "^3", 42 | "@tailwindcss/postcss": "^4", 43 | "autoprefixer": "^10.4.21", 44 | "eslint": "^9", 45 | "eslint-config-next": "15.4.1", 46 | "postcss": "^8.5.6", 47 | "tailwindcss": "^4.1.11", 48 | "tw-animate-css": "^1.3.5" 49 | } 50 | } 51 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/size-pricing/_components/AddSizeModal.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | import Modal from "@/components/dashboard/Modal"; 6 | 7 | import { addSize } from "@/lib/actions/size-price/addSize"; 8 | 9 | import { toast } from "sonner"; 10 | 11 | export default function AddSizeModal({ open, setOpen, onSuccess }) { 12 | const [size, setSize] = useState(""); 13 | const [price, setPrice] = useState(""); 14 | 15 | const handleSubmit = async (e) => { 16 | e.preventDefault(); 17 | 18 | const { data, error } = await addSize({ size, price }); 19 | 20 | if (error && error.message) { 21 | toast.error("Failed to add size: " + error.message); 22 | } else { 23 | toast.success("Size has been added"); 24 | onSuccess?.(data); 25 | setOpen(false); 26 | setSize(""); 27 | setPrice(""); 28 | } 29 | }; 30 | 31 | return ( 32 | <Modal 33 | open={open} 34 | onOpenChange={setOpen} 35 | title="Add Size" 36 | color="default" 37 | fields={[ 38 | { 39 | label: "Size name", 40 | value: size, 41 | onChange: (e) => setSize(e.target.value), 42 | required: true, 43 | }, 44 | { 45 | type: "number", 46 | label: "Price", 47 | value: price === "" ? "" : Number(price), 48 | onChange: (e) => { 49 | const val = e.target.value; 50 | setPrice(val === "" ? "" : parseInt(val)); 51 | }, 52 | required: true, 53 | }, 54 | ]} 55 | onSubmit={handleSubmit} 56 | submitLabel="Add" 57 | showCancel={false} 58 | buttonStyling="bg-primary" 59 | /> 60 | ); 61 | } 62 | ``` -------------------------------------------------------------------------------- /src/components/ui/radio-group.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" 5 | import { CircleIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function RadioGroup({ 10 | className, 11 | ...props 12 | }) { 13 | return ( 14 | <RadioGroupPrimitive.Root 15 | data-slot="radio-group" 16 | className={cn("grid gap-3", className)} 17 | {...props} /> 18 | ); 19 | } 20 | 21 | function RadioGroupItem({ 22 | className, 23 | ...props 24 | }) { 25 | return ( 26 | <RadioGroupPrimitive.Item 27 | data-slot="radio-group-item" 28 | className={cn( 29 | "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", 30 | className 31 | )} 32 | {...props}> 33 | <RadioGroupPrimitive.Indicator 34 | data-slot="radio-group-indicator" 35 | className="relative flex items-center justify-center"> 36 | <CircleIcon 37 | className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" /> 38 | </RadioGroupPrimitive.Indicator> 39 | </RadioGroupPrimitive.Item> 40 | ); 41 | } 42 | 43 | export { RadioGroup, RadioGroupItem } 44 | ``` -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- ``` 1 | <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 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Popover({ 9 | ...props 10 | }) { 11 | return <PopoverPrimitive.Root data-slot="popover" {...props} />; 12 | } 13 | 14 | function PopoverTrigger({ 15 | ...props 16 | }) { 17 | return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />; 18 | } 19 | 20 | function PopoverContent({ 21 | className, 22 | align = "center", 23 | sideOffset = 4, 24 | ...props 25 | }) { 26 | return ( 27 | <PopoverPrimitive.Portal> 28 | <PopoverPrimitive.Content 29 | data-slot="popover-content" 30 | align={align} 31 | sideOffset={sideOffset} 32 | className={cn( 33 | "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", 34 | className 35 | )} 36 | {...props} /> 37 | </PopoverPrimitive.Portal> 38 | ); 39 | } 40 | 41 | function PopoverAnchor({ 42 | ...props 43 | }) { 44 | return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />; 45 | } 46 | 47 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 48 | ``` -------------------------------------------------------------------------------- /src/components/ui/badge.jsx: -------------------------------------------------------------------------------- ```javascript 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const badgeVariants = cva( 8 | "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", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "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", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }) { 34 | const Comp = asChild ? Slot : "span" 35 | 36 | return ( 37 | <Comp 38 | data-slot="badge" 39 | className={cn(badgeVariants({ variant }), className)} 40 | {...props} /> 41 | ); 42 | } 43 | 44 | export { Badge, badgeVariants } 45 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/create/_components/SizeCombobox.jsx: -------------------------------------------------------------------------------- ```javascript 1 | import { useState } from "react"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Command, 6 | CommandEmpty, 7 | CommandGroup, 8 | CommandInput, 9 | CommandItem, 10 | CommandList, 11 | } from "@/components/ui/command"; 12 | import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 13 | 14 | import { Check, ChevronsUpDown } from "lucide-react"; 15 | 16 | import { cn } from "@/lib/utils"; 17 | 18 | export default function SizeCombobox({ sizes, value, onChange }) { 19 | const [open, setOpen] = useState(false); 20 | 21 | return ( 22 | <Popover open={open} onOpenChange={setOpen}> 23 | <PopoverTrigger asChild> 24 | <Button 25 | variant="outline" 26 | role="combobox" 27 | aria-expanded={open} 28 | className="w-full justify-between" 29 | > 30 | {value ? sizes.find((s) => s.id === value)?.size : "Choose size..."} 31 | <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 32 | </Button> 33 | </PopoverTrigger> 34 | <PopoverContent className="w-full p-0"> 35 | <Command> 36 | <CommandInput placeholder="Search size..." /> 37 | <CommandList> 38 | <CommandEmpty>Size not found</CommandEmpty> 39 | <CommandGroup> 40 | {sizes.map((s) => ( 41 | <CommandItem 42 | key={s.id} 43 | value={s.size} 44 | onSelect={() => { 45 | onChange(s.id, s.price); 46 | setOpen(false); 47 | }} 48 | > 49 | {s.size} 50 | <Check 51 | className={cn("ml-auto h-4 w-4", value === s.id ? "opacity-100" : "opacity-0")} 52 | /> 53 | </CommandItem> 54 | ))} 55 | </CommandGroup> 56 | </CommandList> 57 | </Command> 58 | </PopoverContent> 59 | </Popover> 60 | ); 61 | } 62 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/size-pricing/_components/EditSizeModal.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | 5 | import { DialogFooter } from "@/components/ui/dialog"; 6 | import { Button } from "@/components/ui/button"; 7 | import { Input } from "@/components/ui/input"; 8 | 9 | import Modal from "@/components/dashboard/Modal"; 10 | 11 | import { updateSize } from "@/lib/actions/size-price/updateSize"; 12 | 13 | import { toast } from "sonner"; 14 | 15 | export default function EditSizeModal({ open, onOpenChange, data, onSuccess }) { 16 | const [size, setSize] = useState(""); 17 | const [price, setPrice] = useState(""); 18 | 19 | useEffect(() => { 20 | if (data) { 21 | setSize(data.size || ""); 22 | setPrice(data.price || ""); 23 | } 24 | }, [data]); 25 | 26 | const handleUpdate = async () => { 27 | const result = await updateSize(data.id, { size, price }); 28 | 29 | if (result?.success) { 30 | toast.success("Product has been updated"); 31 | onSuccess?.({ ...data, size, price }); 32 | onOpenChange(false); 33 | } else { 34 | toast.error(result?.message || "Failed to update size"); 35 | } 36 | }; 37 | 38 | return ( 39 | <Modal 40 | open={open} 41 | onOpenChange={onOpenChange} 42 | title="Edit Size" 43 | color="blue" 44 | submitLabel="Update" 45 | showCancel={false} 46 | > 47 | <div className="space-y-2"> 48 | <Input placeholder="Size name" value={size} onChange={(e) => setSize(e.target.value)} /> 49 | <Input 50 | placeholder="Price" 51 | type="number" 52 | value={price} 53 | onChange={(e) => { 54 | const val = e.target.value; 55 | setPrice(val === "" ? "" : parseInt(val)); 56 | }} 57 | /> 58 | </div> 59 | 60 | <DialogFooter className="pt-4"> 61 | <Button onClick={handleUpdate} className="bg-sky-500 hover:bg-blue-500 w-full"> 62 | Update 63 | </Button> 64 | </DialogFooter> 65 | </Modal> 66 | ); 67 | } 68 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/products/_components/ProductEditModal.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | 5 | import { DialogFooter } from "@/components/ui/dialog"; 6 | import { Button } from "@/components/ui/button"; 7 | import { Input } from "@/components/ui/input"; 8 | 9 | import Modal from "@/components/dashboard/Modal"; 10 | 11 | import { updateProduct } from "@/lib/actions/products/updateProduct"; 12 | 13 | import { toast } from "sonner"; 14 | 15 | export default function ProductEditModal({ open, onOpenChange, product, onSuccess }) { 16 | const [name, setName] = useState(""); 17 | const [description, setDescription] = useState(""); 18 | 19 | useEffect(() => { 20 | if (product) { 21 | setName(product.name || ""); 22 | setDescription(product.description || ""); 23 | } 24 | }, [product]); 25 | 26 | const handleUpdate = async () => { 27 | const result = await updateProduct(product.id, { name, description }); 28 | 29 | if (result?.success) { 30 | toast.success("Product has been updated"); 31 | onSuccess?.({ ...product, name, description }); 32 | onOpenChange(false); 33 | } else { 34 | toast.error(result?.message || "Failed to update product"); 35 | } 36 | }; 37 | 38 | return ( 39 | <Modal 40 | open={open} 41 | onOpenChange={onOpenChange} 42 | title="Edit Product" 43 | color="blue" 44 | submitLabel="Update" 45 | showCancel={false} 46 | > 47 | <div className="space-y-2"> 48 | <Input placeholder="Product name" value={name} onChange={(e) => setName(e.target.value)} /> 49 | <Input 50 | placeholder="Description" 51 | value={description} 52 | onChange={(e) => setDescription(e.target.value)} 53 | /> 54 | </div> 55 | 56 | <DialogFooter className="pt-4"> 57 | <Button onClick={handleUpdate} className="bg-sky-500 hover:bg-blue-500 w-full"> 58 | Update 59 | </Button> 60 | </DialogFooter> 61 | </Modal> 62 | ); 63 | } 64 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/products/_components/ProductDeleteModal.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { DialogFooter } from "@/components/ui/dialog"; 4 | import { Button } from "@/components/ui/button"; 5 | 6 | import Modal from "@/components/dashboard/Modal"; 7 | 8 | import { deleteProduct } from "@/lib/actions/products/deleteProduct"; 9 | 10 | import { toast } from "sonner"; 11 | 12 | export default function ProductDeleteModal({ open, onOpenChange, productId, onSuccess }) { 13 | const handleDelete = async () => { 14 | const result = await deleteProduct(productId); 15 | 16 | if (result?.success) { 17 | toast.success("Product has been deleted"); 18 | onSuccess?.(); 19 | onOpenChange(false); 20 | } else { 21 | toast.error(result?.message || "Failed to delete product"); 22 | } 23 | }; 24 | 25 | return ( 26 | <Modal 27 | open={open} 28 | onOpenChange={onOpenChange} 29 | title="Delete Product" 30 | color="red" 31 | submitLabel="Delete" 32 | showCancel={false} 33 | > 34 | <p>Are you sure want to delete this product?</p> 35 | <DialogFooter> 36 | <Button 37 | variant="destructive" 38 | onClick={handleDelete} 39 | className={"bg-rose-600 hover:bg-red-600 w-24"} 40 | > 41 | Delete 42 | </Button> 43 | </DialogFooter> 44 | </Modal> 45 | 46 | // <Dialog open={open} onOpenChange={onOpenChange}> 47 | // <DialogContent> 48 | // <DialogHeader> 49 | // <DialogTitle className={"text-red-500"}>Delete Product</DialogTitle> 50 | // </DialogHeader> 51 | // <p>Are you sure want to delete this product?</p> 52 | // <DialogFooter> 53 | // <Button variant="outline" onClick={() => onOpenChange(false)}> 54 | // Cancel 55 | // </Button> 56 | // <Button 57 | // variant="destructive" 58 | // onClick={handleDelete} 59 | // className={"bg-rose-600 hover:bg-red-600"} 60 | // > 61 | // Delete 62 | // </Button> 63 | // </DialogFooter> 64 | // </DialogContent> 65 | // </Dialog> 66 | ); 67 | } 68 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/create/_components/ProductsCombobox.jsx: -------------------------------------------------------------------------------- ```javascript 1 | import { useState } from "react"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Command, 6 | CommandEmpty, 7 | CommandGroup, 8 | CommandInput, 9 | CommandItem, 10 | CommandList, 11 | } from "@/components/ui/command"; 12 | import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 13 | 14 | import { Check, ChevronsUpDown } from "lucide-react"; 15 | 16 | import { cn } from "@/lib/utils"; 17 | 18 | export default function ProductCombobox({ products, value, onChange }) { 19 | const [open, setOpen] = useState(false); 20 | 21 | return ( 22 | <Popover open={open} onOpenChange={setOpen}> 23 | <PopoverTrigger asChild> 24 | <Button 25 | variant="outline" 26 | role="combobox" 27 | aria-expanded={open} 28 | className="w-full justify-between" 29 | > 30 | {value ? products.find((p) => p.id === value)?.name : "Choose item..."} 31 | <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 32 | </Button> 33 | </PopoverTrigger> 34 | <PopoverContent className="w-full p-0"> 35 | <Command> 36 | <CommandInput placeholder="Search item..." /> 37 | <CommandList> 38 | <CommandEmpty>Item not found</CommandEmpty> 39 | <CommandGroup> 40 | {products.map((product) => ( 41 | <CommandItem 42 | key={product.id} 43 | value={product.name} 44 | onSelect={(currentValue) => { 45 | const selected = products.find((p) => p.name === currentValue); 46 | if (selected) { 47 | onChange(selected.id); 48 | setOpen(false); 49 | } 50 | }} 51 | > 52 | {product.name} 53 | <Check 54 | className={cn( 55 | "ml-auto h-4 w-4", 56 | value === product.id ? "opacity-100" : "opacity-0" 57 | )} 58 | /> 59 | </CommandItem> 60 | ))} 61 | </CommandGroup> 62 | </CommandList> 63 | </Command> 64 | </PopoverContent> 65 | </Popover> 66 | ); 67 | } 68 | ``` -------------------------------------------------------------------------------- /src/lib/actions/invoice/updateInvoice.js: -------------------------------------------------------------------------------- ```javascript 1 | "use server"; 2 | 3 | import { supabaseServer } from "@/lib/supabaseServer"; 4 | 5 | export async function updateInvoice({ invoiceId, invoiceData, items }) { 6 | const supabase = await supabaseServer(); 7 | 8 | try { 9 | // is invoice number already exist? 10 | const { data: existing, error: checkError } = await supabase 11 | .from("Invoice") 12 | .select("id") 13 | .eq("invoiceNumber", invoiceData.invoiceNumber) 14 | .neq("id", invoiceId) // exclude current invoice number 15 | .maybeSingle(); 16 | 17 | if (checkError) throw checkError; 18 | if (existing) return { error: "Invoice number already existed!" }; 19 | 20 | const { error: invoiceError } = await supabase 21 | .from("Invoice") 22 | .update({ 23 | invoiceNumber: invoiceData.invoiceNumber, 24 | buyerName: invoiceData.buyerName, 25 | invoiceDate: invoiceData.invoiceDate, 26 | totalPrice: invoiceData.totalPrice, 27 | discount: invoiceData.discount, 28 | shipping: parseInt(invoiceData.shipping) || 0, 29 | status: invoiceData.status, 30 | }) 31 | .eq("id", invoiceId); 32 | 33 | if (invoiceError) { 34 | throw invoiceError; 35 | } 36 | 37 | const { error: deleteError } = await supabase 38 | .from("InvoiceItem") 39 | .delete() 40 | .eq("invoiceId", invoiceId); 41 | 42 | if (deleteError) { 43 | throw deleteError; 44 | } 45 | 46 | // insert new items 47 | const itemsToInsert = items.map((item) => ({ 48 | invoiceId, 49 | productId: item.productId, 50 | sizePriceId: item.sizePriceId, 51 | quantity: item.quantity, 52 | subtotal: item.quantity * (item.price || 0) - (item.discountAmount || 0), 53 | discountAmount: item.discountAmount || 0, 54 | })); 55 | 56 | const { error: insertError } = await supabase.from("InvoiceItem").insert(itemsToInsert); 57 | 58 | if (insertError) { 59 | throw insertError; 60 | } 61 | 62 | return { success: true }; 63 | } catch (err) { 64 | console.error("Error:", err); 65 | return { success: false, error: err.message }; 66 | } 67 | } 68 | ``` -------------------------------------------------------------------------------- /src/components/ui/card.jsx: -------------------------------------------------------------------------------- ```javascript 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ 6 | className, 7 | ...props 8 | }) { 9 | return ( 10 | <div 11 | data-slot="card" 12 | className={cn( 13 | "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", 14 | className 15 | )} 16 | {...props} /> 17 | ); 18 | } 19 | 20 | function CardHeader({ 21 | className, 22 | ...props 23 | }) { 24 | return ( 25 | <div 26 | data-slot="card-header" 27 | className={cn( 28 | "@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", 29 | className 30 | )} 31 | {...props} /> 32 | ); 33 | } 34 | 35 | function CardTitle({ 36 | className, 37 | ...props 38 | }) { 39 | return ( 40 | <div 41 | data-slot="card-title" 42 | className={cn("leading-none font-semibold", className)} 43 | {...props} /> 44 | ); 45 | } 46 | 47 | function CardDescription({ 48 | className, 49 | ...props 50 | }) { 51 | return ( 52 | <div 53 | data-slot="card-description" 54 | className={cn("text-muted-foreground text-sm", className)} 55 | {...props} /> 56 | ); 57 | } 58 | 59 | function CardAction({ 60 | className, 61 | ...props 62 | }) { 63 | return ( 64 | <div 65 | data-slot="card-action" 66 | className={cn( 67 | "col-start-2 row-span-2 row-start-1 self-start justify-self-end", 68 | className 69 | )} 70 | {...props} /> 71 | ); 72 | } 73 | 74 | function CardContent({ 75 | className, 76 | ...props 77 | }) { 78 | return (<div data-slot="card-content" className={cn("px-6", className)} {...props} />); 79 | } 80 | 81 | function CardFooter({ 82 | className, 83 | ...props 84 | }) { 85 | return ( 86 | <div 87 | data-slot="card-footer" 88 | className={cn("flex items-center px-6 [.border-t]:pt-6", className)} 89 | {...props} /> 90 | ); 91 | } 92 | 93 | export { 94 | Card, 95 | CardHeader, 96 | CardFooter, 97 | CardTitle, 98 | CardAction, 99 | CardDescription, 100 | CardContent, 101 | } 102 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/_components/StatusCombobox.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | import { 7 | Command, 8 | CommandEmpty, 9 | CommandGroup, 10 | CommandInput, 11 | CommandItem, 12 | CommandList, 13 | } from "@/components/ui/command"; 14 | import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 15 | 16 | import { Check, ChevronsUpDown } from "lucide-react"; 17 | 18 | import { cn } from "@/lib/utils"; 19 | 20 | const statuses = [ 21 | { label: "Pending", value: "pending" }, 22 | { label: "Success", value: "success" }, 23 | { label: "Canceled", value: "canceled" }, 24 | ]; 25 | 26 | export default function StatusCombobox({ value, onChange }) { 27 | const [open, setOpen] = useState(false); 28 | 29 | const selected = statuses.find((s) => s.value === value); 30 | 31 | return ( 32 | <Popover open={open} onOpenChange={setOpen}> 33 | <PopoverTrigger asChild> 34 | <Button 35 | variant="outline" 36 | role="combobox" 37 | aria-expanded={open} 38 | className="w-full justify-between" 39 | > 40 | {selected ? selected.label : "Choose status..."} 41 | <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> 42 | </Button> 43 | </PopoverTrigger> 44 | <PopoverContent className="w-full p-0"> 45 | <Command> 46 | <CommandInput placeholder="Search status..." /> 47 | <CommandList> 48 | <CommandEmpty>Status not found</CommandEmpty> 49 | <CommandGroup> 50 | {statuses.map((s) => ( 51 | <CommandItem 52 | key={s.value} 53 | value={s.value} 54 | onSelect={(currentVal) => { 55 | onChange(currentVal); 56 | setOpen(false); 57 | }} 58 | > 59 | {s.label} 60 | <Check 61 | className={cn( 62 | "ml-auto h-4 w-4", 63 | value === s.value ? "opacity-100" : "opacity-0" 64 | )} 65 | /> 66 | </CommandItem> 67 | ))} 68 | </CommandGroup> 69 | </CommandList> 70 | </Command> 71 | </PopoverContent> 72 | </Popover> 73 | ); 74 | } 75 | ``` -------------------------------------------------------------------------------- /src/components/dashboard/Modal.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogHeader, 7 | DialogTitle, 8 | DialogFooter, 9 | } from "@/components/ui/dialog"; 10 | import { Button } from "@/components/ui/button"; 11 | import { Input } from "@/components/ui/input"; 12 | import { Label } from "@/components/ui/label"; 13 | 14 | export default function Modal({ 15 | open, 16 | onOpenChange, 17 | title, 18 | color = "default", // 'default' | 'red' | 'blue' | 'green' (styling title/button) 19 | fields = [], // [{ label, placeholder, value, onChange, required }] 20 | onSubmit, 21 | submitLabel = "Submit", 22 | footerButtons, // override footer 23 | children, // optional custom content 24 | buttonStyling, 25 | }) { 26 | const getColorClass = () => { 27 | switch (color) { 28 | case "red": 29 | return "text-red-500"; 30 | case "blue": 31 | return "text-blue-500"; 32 | case "green": 33 | return "text-green-500"; 34 | default: 35 | return ""; 36 | } 37 | }; 38 | 39 | return ( 40 | <Dialog open={open} onOpenChange={onOpenChange}> 41 | <DialogContent> 42 | <DialogHeader> 43 | <DialogTitle className={getColorClass()}>{title}</DialogTitle> 44 | </DialogHeader> 45 | 46 | {/* CASE 1: children custom (non-form) */} 47 | {children && children} 48 | 49 | {!children && fields.length > 0 && ( 50 | <form onSubmit={onSubmit} className="space-y-4 mt-4"> 51 | {fields.map((field) => ( 52 | <div className="space-y-1" key={field.label || field.placeholder || field.value}> 53 | {field.label && <Label>{field.label}</Label>} 54 | <Input 55 | type={field.type || "text"} 56 | placeholder={field.placeholder} 57 | value={field.value} 58 | onChange={field.onChange} 59 | required={field.required} 60 | /> 61 | </div> 62 | ))} 63 | <DialogFooter> 64 | <Button type="submit" className={`w-full ${buttonStyling}`}> 65 | {submitLabel} 66 | </Button> 67 | </DialogFooter> 68 | </form> 69 | )} 70 | 71 | {footerButtons && <DialogFooter>{footerButtons}</DialogFooter>} 72 | </DialogContent> 73 | </Dialog> 74 | ); 75 | } 76 | ``` -------------------------------------------------------------------------------- /src/components/ui/button.jsx: -------------------------------------------------------------------------------- ```javascript 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "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", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "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", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }) { 45 | const Comp = asChild ? Slot : "button" 46 | 47 | return ( 48 | <Comp 49 | data-slot="button" 50 | className={cn(buttonVariants({ variant, size, className }))} 51 | {...props} /> 52 | ); 53 | } 54 | 55 | export { Button, buttonVariants } 56 | ``` -------------------------------------------------------------------------------- /src/lib/actions/invoice/submitInvoice.js: -------------------------------------------------------------------------------- ```javascript 1 | "use server"; 2 | 3 | import { supabaseServer } from "@/lib/supabaseServer"; 4 | 5 | export const submitInvoice = async ({ 6 | invoiceNumber, 7 | buyerName, 8 | invoiceDate, 9 | shippingPrice, 10 | discountAmount = 0, 11 | totalPrice, 12 | items, 13 | user, 14 | }) => { 15 | if (!user) { 16 | return { error: "User is not login!" }; 17 | } 18 | 19 | if (!invoiceNumber.trim() || !buyerName.trim() || items.length === 0) { 20 | return { error: "Invoice number, buyer name, and at least one item are required!" }; 21 | } 22 | 23 | const supabase = await supabaseServer(); 24 | 25 | // validate number inputs 26 | const shipping = Number.isNaN(parseInt(shippingPrice)) ? 0 : parseInt(shippingPrice); 27 | const discount = Number.isNaN(parseInt(discountAmount)) ? 0 : parseInt(discountAmount); 28 | const total = Number.isNaN(parseInt(totalPrice)) ? 0 : parseInt(totalPrice); 29 | 30 | try { 31 | // is invoice number already exist? 32 | const { data: existing, error: checkError } = await supabase 33 | .from("Invoice") 34 | .select("id") 35 | .eq("invoiceNumber", invoiceNumber) 36 | .maybeSingle(); 37 | 38 | if (checkError) throw checkError; 39 | if (existing) return { error: "Invoice number already existed!" }; 40 | 41 | // insert invoice 42 | const { data: invoice, error: invoiceError } = await supabase 43 | .from("Invoice") 44 | .insert([ 45 | { 46 | invoiceNumber, 47 | buyerName, 48 | invoiceDate: new Date(invoiceDate), 49 | shipping, 50 | discount, 51 | totalPrice: total, 52 | status: "pending", 53 | userId: user.id, 54 | }, 55 | ]) 56 | .select() 57 | .single(); 58 | 59 | if (invoiceError || !invoice) throw invoiceError || new Error("Failed to insert invoice!"); 60 | 61 | // insert invoice items 62 | const invoiceItems = items.map((item) => ({ 63 | invoiceId: invoice.id, 64 | productId: item.productId, 65 | sizePriceId: item.sizePriceId, 66 | quantity: item.quantity, 67 | subtotal: item.total, 68 | discountAmount: item.discountAmount || 0, 69 | })); 70 | 71 | const { error: itemError } = await supabase.from("InvoiceItem").insert(invoiceItems); 72 | 73 | if (itemError) throw itemError; 74 | 75 | return { success: true, message: "Invoice has been created", invoice }; 76 | } catch (err) { 77 | return { error: "Something went wrong while saving invoice!" }; 78 | } 79 | }; 80 | ``` -------------------------------------------------------------------------------- /src/components/dashboard/DatePicker.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { CalendarIcon } from "lucide-react"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { Calendar } from "@/components/ui/calendar"; 8 | import { Input } from "@/components/ui/input"; 9 | import { Label } from "@/components/ui/label"; 10 | import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 11 | 12 | function formatDate(date) { 13 | if (!date) { 14 | return ""; 15 | } 16 | 17 | return date.toLocaleDateString("id-ID", { 18 | day: "2-digit", 19 | month: "long", 20 | year: "numeric", 21 | }); 22 | } 23 | 24 | function isValidDate(date) { 25 | if (!date) { 26 | return false; 27 | } 28 | return !isNaN(date.getTime()); 29 | } 30 | 31 | export default function DatePicker({ invoiceDate, setInvoiceDate }) { 32 | const [open, setOpen] = useState(false); 33 | const [selectedDate, setSelectedDate] = useState( 34 | invoiceDate ? new Date(invoiceDate) : new Date() 35 | ); 36 | 37 | return ( 38 | <div className="flex flex-col gap-3"> 39 | <div className="relative flex gap-2"> 40 | <Input 41 | id="date" 42 | value={formatDate(new Date(invoiceDate))} 43 | placeholder="June 01, 2025" 44 | className="bg-background pr-10" 45 | onChange={(e) => { 46 | const date = new Date(e.target.value); 47 | if (!isNaN(date)) { 48 | date.setHours(12, 0, 0, 0); 49 | setSelectedDate(date); 50 | setInvoiceDate(date.toISOString()); 51 | } 52 | }} 53 | onKeyDown={(e) => { 54 | if (e.key === "ArrowDown") { 55 | e.preventDefault(); 56 | setOpen(true); 57 | } 58 | }} 59 | /> 60 | <Popover open={open} onOpenChange={setOpen}> 61 | <PopoverTrigger asChild> 62 | <Button 63 | id="date-picker" 64 | variant="ghost" 65 | className="absolute top-1/2 right-2 size-6 -translate-y-1/2" 66 | > 67 | <CalendarIcon className="size-3.5" /> 68 | <span className="sr-only">Select date</span> 69 | </Button> 70 | </PopoverTrigger> 71 | <PopoverContent 72 | className="w-auto overflow-hidden p-0" 73 | align="end" 74 | alignOffset={-8} 75 | sideOffset={10} 76 | > 77 | <Calendar 78 | mode="single" 79 | selected={selectedDate} 80 | captionLayout="dropdown" 81 | month={selectedDate} 82 | onMonthChange={(month) => setSelectedDate(month)} 83 | onSelect={(date) => { 84 | if (date) { 85 | setSelectedDate(date); 86 | 87 | const withNoon = new Date(date); 88 | withNoon.setHours(12, 0, 0, 0); 89 | setInvoiceDate(withNoon.toISOString()); 90 | 91 | setOpen(false); 92 | } 93 | }} 94 | /> 95 | </PopoverContent> 96 | </Popover> 97 | </div> 98 | </div> 99 | ); 100 | } 101 | ``` -------------------------------------------------------------------------------- /src/lib/utils.js: -------------------------------------------------------------------------------- ```javascript 1 | import { clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | export function getStatusVariant(status) { 9 | switch (status) { 10 | case "success": 11 | 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"; 12 | case "pending": 13 | 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"; 14 | case "canceled": 15 | 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"; 16 | default: 17 | 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"; 18 | } 19 | } 20 | 21 | export function toTitleCase(str) { 22 | return str.replace( 23 | /\w\S*/g, 24 | (text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase() 25 | ); 26 | } 27 | 28 | export function formatInvoiceDateTime(dateStr, timeStr) { 29 | const date = new Date(dateStr); 30 | const time = new Date(timeStr); 31 | 32 | const day = date.getDate().toString().padStart(2, "0"); 33 | const month = (date.getMonth() + 1).toString().padStart(2, "0"); 34 | const year = date.getFullYear(); 35 | 36 | const hours = time.getHours().toString().padStart(2, "0"); 37 | const minutes = time.getMinutes().toString().padStart(2, "0"); 38 | 39 | return `${day}/${month}/${year} ${hours}:${minutes}`; 40 | } 41 | 42 | export function formatDateFilename(date) { 43 | if (!date) return ""; 44 | 45 | const dates = new Date(date); 46 | const day = String(dates.getDate()).padStart(2, "0"); 47 | const month = String(dates.getMonth() + 1).padStart(2, "0"); 48 | const year = dates.getFullYear(); 49 | 50 | return `${day}/${month}/${year}`; 51 | } 52 | 53 | export function getPageTitle(subTitle) { 54 | return `Cheese Stick Koe - ${subTitle}`; 55 | } 56 | 57 | export function calculateDiscountPercent({ 58 | quantity, 59 | price, 60 | discountMode, 61 | discountInput, 62 | discountAmount, 63 | }) { 64 | const rawTotal = (quantity || 0) * (price || 0); 65 | 66 | if (!rawTotal) return "0"; 67 | 68 | const amount = 69 | discountMode === "percent" 70 | ? (parseFloat(discountInput) / 100) * rawTotal 71 | : parseInt(discountInput) || discountAmount || 0; 72 | 73 | const percent = (amount / rawTotal) * 100; 74 | 75 | return isNaN(percent) ? "0" : percent.toFixed(2); 76 | } 77 | 78 | export function calculateDiscountAmount({ quantity, price, discountInput, discountMode }) { 79 | const rawTotal = (quantity || 0) * (price || 0); 80 | if (discountMode === "percent") { 81 | return Math.round(((parseFloat(discountInput) || 0) / 100) * rawTotal); 82 | } 83 | return parseInt(discountInput) || 0; 84 | } 85 | ``` -------------------------------------------------------------------------------- /src/components/dashboard/Sidebar.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { usePathname, useRouter } from "next/navigation"; 5 | import { useState } from "react"; 6 | import { Menu, X } from "lucide-react"; 7 | 8 | const navItems = [ 9 | { name: "Dashboard", href: "/dashboard" }, 10 | { name: "Products", href: "/dashboard/products" }, 11 | { name: "Size & Price", href: "/dashboard/size-pricing" }, 12 | { 13 | name: "Invoices", 14 | href: "/dashboard/invoices", 15 | children: [ 16 | { name: "List Invoices", href: "/dashboard/invoices" }, 17 | { name: "Create Invoice", href: "/dashboard/invoices/create" }, 18 | ], 19 | }, 20 | ]; 21 | 22 | export function Sidebar() { 23 | const pathname = usePathname(); 24 | const router = useRouter(); 25 | const [open, setOpen] = useState(false); 26 | 27 | const handleLogout = async () => { 28 | try { 29 | const res = await fetch("/api/logout", { 30 | method: "POST", 31 | }); 32 | if (res.ok) { 33 | router.push("/"); 34 | } else { 35 | console.error("Logout failed"); 36 | } 37 | } catch (err) { 38 | console.error("Logout error:", err); 39 | } 40 | }; 41 | 42 | return ( 43 | <> 44 | {/* Burger Icon */} 45 | <button onClick={() => setOpen(!open)} className="md:hidden p-4 focus:outline-none"> 46 | {open ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />} 47 | </button> 48 | 49 | {/* Sidebar */} 50 | <aside 51 | className={`${ 52 | open ? "block" : "hidden" 53 | } 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`} 54 | > 55 | {/* Header */} 56 | <div className="p-4 border-b border-[#f1e3db] flex justify-between items-center bg-[#fff2ea]"> 57 | <h1 className="text-lg font-bold text-[#6D2315] tracking-wide">Invoice App</h1> 58 | <button onClick={() => setOpen(false)} className="md:hidden"> 59 | <X className="w-5 h-5 text-[#6D2315]" /> 60 | </button> 61 | </div> 62 | 63 | {/* Nav Items */} 64 | <nav className="flex flex-col p-4 space-y-2 text-sm text-gray-700"> 65 | {navItems.map((item) => { 66 | const isActive = pathname === item.href || pathname.startsWith(item.href + "/"); 67 | 68 | if (item.children) { 69 | return ( 70 | <div key={item.href} className="space-y-1"> 71 | <div 72 | className={`px-3 py-2 rounded-md font-semibold ${ 73 | isActive ? "bg-[#f9e0cd] text-[#6D2315]" : "text-gray-700 hover:bg-[#fceee4]" 74 | }`} 75 | > 76 | {item.name} 77 | </div> 78 | <div className="ml-4 space-y-1"> 79 | {item.children.map((child) => ( 80 | <Link 81 | key={child.href} 82 | href={child.href} 83 | className={`block px-3 py-2 rounded-md text-sm ${ 84 | pathname === child.href 85 | ? "bg-[#f9e0cd] text-[#6D2315] font-semibold" 86 | : "hover:bg-[#fceee4] text-gray-700" 87 | }`} 88 | onClick={() => setOpen(false)} 89 | > 90 | {child.name} 91 | </Link> 92 | ))} 93 | </div> 94 | </div> 95 | ); 96 | } 97 | 98 | return ( 99 | <Link 100 | key={item.href} 101 | href={item.href} 102 | className={`px-3 py-2 rounded-md ${ 103 | pathname === item.href 104 | ? "bg-[#f9e0cd] text-[#6D2315] font-semibold" 105 | : "hover:bg-[#fceee4] text-gray-700" 106 | }`} 107 | onClick={() => setOpen(false)} 108 | > 109 | {item.name} 110 | </Link> 111 | ); 112 | })} 113 | 114 | <button 115 | onClick={handleLogout} 116 | className="mt-4 text-sm text-left text-red-500 hover:bg-red-100 px-3 py-2 rounded-md" 117 | > 118 | Logout 119 | </button> 120 | </nav> 121 | </aside> 122 | </> 123 | ); 124 | } 125 | ``` -------------------------------------------------------------------------------- /src/components/ui/dialog.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { XIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Dialog({ 10 | ...props 11 | }) { 12 | return <DialogPrimitive.Root data-slot="dialog" {...props} />; 13 | } 14 | 15 | function DialogTrigger({ 16 | ...props 17 | }) { 18 | return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />; 19 | } 20 | 21 | function DialogPortal({ 22 | ...props 23 | }) { 24 | return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />; 25 | } 26 | 27 | function DialogClose({ 28 | ...props 29 | }) { 30 | return <DialogPrimitive.Close data-slot="dialog-close" {...props} />; 31 | } 32 | 33 | function DialogOverlay({ 34 | className, 35 | ...props 36 | }) { 37 | return ( 38 | <DialogPrimitive.Overlay 39 | data-slot="dialog-overlay" 40 | className={cn( 41 | "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", 42 | className 43 | )} 44 | {...props} /> 45 | ); 46 | } 47 | 48 | function DialogContent({ 49 | className, 50 | children, 51 | showCloseButton = true, 52 | ...props 53 | }) { 54 | return ( 55 | <DialogPortal data-slot="dialog-portal"> 56 | <DialogOverlay /> 57 | <DialogPrimitive.Content 58 | data-slot="dialog-content" 59 | className={cn( 60 | "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", 61 | className 62 | )} 63 | {...props}> 64 | {children} 65 | {showCloseButton && ( 66 | <DialogPrimitive.Close 67 | data-slot="dialog-close" 68 | 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"> 69 | <XIcon /> 70 | <span className="sr-only">Close</span> 71 | </DialogPrimitive.Close> 72 | )} 73 | </DialogPrimitive.Content> 74 | </DialogPortal> 75 | ); 76 | } 77 | 78 | function DialogHeader({ 79 | className, 80 | ...props 81 | }) { 82 | return ( 83 | <div 84 | data-slot="dialog-header" 85 | className={cn("flex flex-col gap-2 text-center sm:text-left", className)} 86 | {...props} /> 87 | ); 88 | } 89 | 90 | function DialogFooter({ 91 | className, 92 | ...props 93 | }) { 94 | return ( 95 | <div 96 | data-slot="dialog-footer" 97 | className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} 98 | {...props} /> 99 | ); 100 | } 101 | 102 | function DialogTitle({ 103 | className, 104 | ...props 105 | }) { 106 | return ( 107 | <DialogPrimitive.Title 108 | data-slot="dialog-title" 109 | className={cn("text-lg leading-none font-semibold", className)} 110 | {...props} /> 111 | ); 112 | } 113 | 114 | function DialogDescription({ 115 | className, 116 | ...props 117 | }) { 118 | return ( 119 | <DialogPrimitive.Description 120 | data-slot="dialog-description" 121 | className={cn("text-muted-foreground text-sm", className)} 122 | {...props} /> 123 | ); 124 | } 125 | 126 | export { 127 | Dialog, 128 | DialogClose, 129 | DialogContent, 130 | DialogDescription, 131 | DialogFooter, 132 | DialogHeader, 133 | DialogOverlay, 134 | DialogPortal, 135 | DialogTitle, 136 | DialogTrigger, 137 | } 138 | ``` -------------------------------------------------------------------------------- /src/components/ui/drawer.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Drawer as DrawerPrimitive } from "vaul" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Drawer({ 9 | ...props 10 | }) { 11 | return <DrawerPrimitive.Root data-slot="drawer" {...props} />; 12 | } 13 | 14 | function DrawerTrigger({ 15 | ...props 16 | }) { 17 | return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />; 18 | } 19 | 20 | function DrawerPortal({ 21 | ...props 22 | }) { 23 | return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />; 24 | } 25 | 26 | function DrawerClose({ 27 | ...props 28 | }) { 29 | return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />; 30 | } 31 | 32 | function DrawerOverlay({ 33 | className, 34 | ...props 35 | }) { 36 | return ( 37 | <DrawerPrimitive.Overlay 38 | data-slot="drawer-overlay" 39 | className={cn( 40 | "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", 41 | className 42 | )} 43 | {...props} /> 44 | ); 45 | } 46 | 47 | function DrawerContent({ 48 | className, 49 | children, 50 | ...props 51 | }) { 52 | return ( 53 | <DrawerPortal data-slot="drawer-portal"> 54 | <DrawerOverlay /> 55 | <DrawerPrimitive.Content 56 | data-slot="drawer-content" 57 | className={cn( 58 | "group/drawer-content bg-background fixed z-50 flex h-auto flex-col", 59 | "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", 60 | "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", 61 | "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", 62 | "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", 63 | className 64 | )} 65 | {...props}> 66 | <div 67 | 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" /> 68 | {children} 69 | </DrawerPrimitive.Content> 70 | </DrawerPortal> 71 | ); 72 | } 73 | 74 | function DrawerHeader({ 75 | className, 76 | ...props 77 | }) { 78 | return ( 79 | <div 80 | data-slot="drawer-header" 81 | className={cn( 82 | "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", 83 | className 84 | )} 85 | {...props} /> 86 | ); 87 | } 88 | 89 | function DrawerFooter({ 90 | className, 91 | ...props 92 | }) { 93 | return ( 94 | <div 95 | data-slot="drawer-footer" 96 | className={cn("mt-auto flex flex-col gap-2 p-4", className)} 97 | {...props} /> 98 | ); 99 | } 100 | 101 | function DrawerTitle({ 102 | className, 103 | ...props 104 | }) { 105 | return ( 106 | <DrawerPrimitive.Title 107 | data-slot="drawer-title" 108 | className={cn("text-foreground font-semibold", className)} 109 | {...props} /> 110 | ); 111 | } 112 | 113 | function DrawerDescription({ 114 | className, 115 | ...props 116 | }) { 117 | return ( 118 | <DrawerPrimitive.Description 119 | data-slot="drawer-description" 120 | className={cn("text-muted-foreground text-sm", className)} 121 | {...props} /> 122 | ); 123 | } 124 | 125 | export { 126 | Drawer, 127 | DrawerPortal, 128 | DrawerOverlay, 129 | DrawerTrigger, 130 | DrawerClose, 131 | DrawerContent, 132 | DrawerHeader, 133 | DrawerFooter, 134 | DrawerTitle, 135 | DrawerDescription, 136 | } 137 | ``` -------------------------------------------------------------------------------- /src/app/_components/LoginForm.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | 6 | import { supabaseBrowser } from "@/lib/supabaseBrowser"; 7 | 8 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 9 | import { Input } from "@/components/ui/input"; 10 | import { Button } from "@/components/ui/button"; 11 | 12 | import { toast } from "sonner"; 13 | import { Loader2Icon, LockIcon } from "lucide-react"; 14 | 15 | import { Controller, useForm } from "react-hook-form"; 16 | 17 | export default function LoginForm() { 18 | const router = useRouter(); 19 | const [loading, setLoading] = useState(false); 20 | 21 | useEffect(() => { 22 | // set loading state false when route change complete 23 | const handleComplete = () => setLoading(false); 24 | 25 | // Listen to route change events 26 | router.events?.on("routeChangeComplete", handleComplete); 27 | router.events?.on("routeChangeError", handleComplete); 28 | 29 | return () => { 30 | router.events?.off("routeChangeComplete", handleComplete); 31 | router.events?.off("routeChangeError", handleComplete); 32 | }; 33 | }, [router]); 34 | 35 | const { 36 | control, 37 | handleSubmit, 38 | formState: { errors }, 39 | } = useForm({ 40 | defaultValues: { 41 | email: "", 42 | password: "", 43 | }, 44 | }); 45 | 46 | const onSubmit = async (data) => { 47 | try { 48 | setLoading(true); 49 | const supabase = supabaseBrowser(); 50 | const { error } = await supabase.auth.signInWithPassword({ 51 | email: data.email, 52 | password: data.password, 53 | }); 54 | 55 | if (error) throw error; 56 | 57 | await router.push("/dashboard"); 58 | } catch (err) { 59 | toast.error(err.message); 60 | setLoading(false); 61 | } 62 | }; 63 | 64 | return ( 65 | <div className="min-h-screen flex items-center justify-center px-4 bg-gradient-to-br from-[#fef6f3] to-[#f1f5f9]"> 66 | <Card className="w-full max-w-md shadow-xl border border-gray-200"> 67 | <CardHeader className="text-center space-y-2"> 68 | <div className="flex justify-center"> 69 | <div className="w-14 h-14 rounded-full bg-[#6d2315] text-white flex items-center justify-center text-2xl font-bold"> 70 | <LockIcon /> 71 | </div> 72 | </div> 73 | <CardTitle className="text-2xl font-bold text-[#6d2315]">Welcome Back</CardTitle> 74 | <p className="text-sm text-gray-500">Please login to your account</p> 75 | </CardHeader> 76 | 77 | <CardContent> 78 | <form onSubmit={handleSubmit(onSubmit)} className="space-y-5"> 79 | <div className="space-y-1"> 80 | <label htmlFor="email" className="text-sm font-medium text-gray-700"> 81 | Email 82 | </label> 83 | <Controller 84 | name="email" 85 | control={control} 86 | rules={{ required: "Email is required!" }} 87 | render={({ field }) => ( 88 | <Input 89 | {...field} 90 | id="email" 91 | type="email" 92 | placeholder="[email protected]" 93 | required 94 | /> 95 | )} 96 | /> 97 | {errors.email && ( 98 | <p role="alert" className="text-sm text-red-500"> 99 | {errors.email.message} 100 | </p> 101 | )} 102 | </div> 103 | 104 | <div className="space-y-1"> 105 | <label htmlFor="password" className="text-sm font-medium text-gray-700"> 106 | Password 107 | </label> 108 | <Controller 109 | name="password" 110 | control={control} 111 | rules={{ required: "Password is required!" }} 112 | render={({ field }) => ( 113 | <Input {...field} id="password" type="password" placeholder="••••••••" required /> 114 | )} 115 | /> 116 | {errors.password && ( 117 | <p role="alert" className="text-sm text-red-500"> 118 | {errors.password.message} 119 | </p> 120 | )} 121 | </div> 122 | 123 | {/* {error && <p className="text-sm text-red-500 text-center">{error}</p>} */} 124 | 125 | <Button 126 | type="submit" 127 | className="w-full bg-[#6d2315] hover:bg-[#591c10]" 128 | disabled={loading} 129 | > 130 | {loading ? ( 131 | <> 132 | <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> 133 | Logging in... 134 | </> 135 | ) : ( 136 | "Login" 137 | )} 138 | </Button> 139 | </form> 140 | </CardContent> 141 | </Card> 142 | </div> 143 | ); 144 | } 145 | ``` -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- ```css 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | @theme inline { 7 | --color-background: var(--background); 8 | --color-foreground: var(--foreground); 9 | --font-sans: var(--font-geist-sans); 10 | --font-mono: var(--font-geist-mono); 11 | --color-sidebar-ring: var(--sidebar-ring); 12 | --color-sidebar-border: var(--sidebar-border); 13 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 14 | --color-sidebar-accent: var(--sidebar-accent); 15 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 16 | --color-sidebar-primary: var(--sidebar-primary); 17 | --color-sidebar-foreground: var(--sidebar-foreground); 18 | --color-sidebar: var(--sidebar); 19 | --color-chart-5: var(--chart-5); 20 | --color-chart-4: var(--chart-4); 21 | --color-chart-3: var(--chart-3); 22 | --color-chart-2: var(--chart-2); 23 | --color-chart-1: var(--chart-1); 24 | --color-ring: var(--ring); 25 | --color-input: var(--input); 26 | --color-border: var(--border); 27 | --color-destructive: var(--destructive); 28 | --color-accent-foreground: var(--accent-foreground); 29 | --color-accent: var(--accent); 30 | --color-muted-foreground: var(--muted-foreground); 31 | --color-muted: var(--muted); 32 | --color-secondary-foreground: var(--secondary-foreground); 33 | --color-secondary: var(--secondary); 34 | --color-primary-foreground: var(--primary-foreground); 35 | --color-primary: var(--primary); 36 | --color-popover-foreground: var(--popover-foreground); 37 | --color-popover: var(--popover); 38 | --color-card-foreground: var(--card-foreground); 39 | --color-card: var(--card); 40 | --radius-sm: calc(var(--radius) - 4px); 41 | --radius-md: calc(var(--radius) - 2px); 42 | --radius-lg: var(--radius); 43 | --radius-xl: calc(var(--radius) + 4px); 44 | } 45 | 46 | :root { 47 | --radius: 0.625rem; 48 | --background: oklch(1 0 0); 49 | --foreground: oklch(0.145 0 0); 50 | --card: oklch(1 0 0); 51 | --card-foreground: oklch(0.145 0 0); 52 | --popover: oklch(1 0 0); 53 | --popover-foreground: oklch(0.145 0 0); 54 | --primary: oklch(0.205 0 0); 55 | --primary-foreground: oklch(0.985 0 0); 56 | --secondary: oklch(0.97 0 0); 57 | --secondary-foreground: oklch(0.205 0 0); 58 | --muted: oklch(0.97 0 0); 59 | --muted-foreground: oklch(0.556 0 0); 60 | --accent: oklch(0.97 0 0); 61 | --accent-foreground: oklch(0.205 0 0); 62 | --destructive: oklch(0.577 0.245 27.325); 63 | --border: oklch(0.922 0 0); 64 | --input: oklch(0.922 0 0); 65 | --ring: oklch(0.708 0 0); 66 | --chart-1: oklch(0.646 0.222 41.116); 67 | --chart-2: oklch(0.6 0.118 184.704); 68 | --chart-3: oklch(0.398 0.07 227.392); 69 | --chart-4: oklch(0.828 0.189 84.429); 70 | --chart-5: oklch(0.769 0.188 70.08); 71 | --sidebar: oklch(0.985 0 0); 72 | --sidebar-foreground: oklch(0.145 0 0); 73 | --sidebar-primary: oklch(0.205 0 0); 74 | --sidebar-primary-foreground: oklch(0.985 0 0); 75 | --sidebar-accent: oklch(0.97 0 0); 76 | --sidebar-accent-foreground: oklch(0.205 0 0); 77 | --sidebar-border: oklch(0.922 0 0); 78 | --sidebar-ring: oklch(0.708 0 0); 79 | } 80 | 81 | .dark { 82 | --background: oklch(0.145 0 0); 83 | --foreground: oklch(0.985 0 0); 84 | --card: oklch(0.205 0 0); 85 | --card-foreground: oklch(0.985 0 0); 86 | --popover: oklch(0.205 0 0); 87 | --popover-foreground: oklch(0.985 0 0); 88 | --primary: oklch(0.922 0 0); 89 | --primary-foreground: oklch(0.205 0 0); 90 | --secondary: oklch(0.269 0 0); 91 | --secondary-foreground: oklch(0.985 0 0); 92 | --muted: oklch(0.269 0 0); 93 | --muted-foreground: oklch(0.708 0 0); 94 | --accent: oklch(0.269 0 0); 95 | --accent-foreground: oklch(0.985 0 0); 96 | --destructive: oklch(0.704 0.191 22.216); 97 | --border: oklch(1 0 0 / 10%); 98 | --input: oklch(1 0 0 / 15%); 99 | --ring: oklch(0.556 0 0); 100 | --chart-1: oklch(0.488 0.243 264.376); 101 | --chart-2: oklch(0.696 0.17 162.48); 102 | --chart-3: oklch(0.769 0.188 70.08); 103 | --chart-4: oklch(0.627 0.265 303.9); 104 | --chart-5: oklch(0.645 0.246 16.439); 105 | --sidebar: oklch(0.205 0 0); 106 | --sidebar-foreground: oklch(0.985 0 0); 107 | --sidebar-primary: oklch(0.488 0.243 264.376); 108 | --sidebar-primary-foreground: oklch(0.985 0 0); 109 | --sidebar-accent: oklch(0.269 0 0); 110 | --sidebar-accent-foreground: oklch(0.985 0 0); 111 | --sidebar-border: oklch(1 0 0 / 10%); 112 | --sidebar-ring: oklch(0.556 0 0); 113 | } 114 | 115 | @layer base { 116 | * { 117 | @apply border-border outline-ring/50; 118 | } 119 | body { 120 | @apply bg-background text-foreground; 121 | } 122 | } 123 | 124 | button { 125 | cursor: pointer; 126 | } 127 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/_components/InvoiceDownloadModal.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useRef, useState } from "react"; 4 | 5 | import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; 6 | import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; 7 | import { Button } from "@/components/ui/button"; 8 | import { Label } from "@/components/ui/label"; 9 | 10 | import InvoicePreview from "./InvoicePreview"; 11 | 12 | import { exportInvoiceToPng } from "@/lib/exportToPng"; 13 | import { formatDateFilename } from "@/lib/utils"; 14 | 15 | import { toast } from "sonner"; 16 | 17 | export default function InvoiceDownloadModal({ open, onOpenChange, invoice, invoiceItems }) { 18 | const invoiceRef = useRef(null); 19 | const hiddenRef = useRef(null); 20 | 21 | const [dataReady, setDataReady] = useState(false); 22 | const [isInvoiceReady, setIsInvoiceReady] = useState(false); 23 | const [isDownloading, setIsDownloading] = useState(false); 24 | 25 | const [shippingType, setShippingType] = useState(""); 26 | 27 | const handleDownload = async () => { 28 | if (isDownloading) { 29 | toast.error("Wait until download complete"); 30 | return; 31 | } 32 | 33 | if (!isInvoiceReady || !dataReady) { 34 | toast.error("Wait a second. Invoice is loading .."); 35 | return; 36 | } 37 | 38 | setIsDownloading(true); 39 | 40 | try { 41 | await document.fonts.ready; 42 | await new Promise((r) => setTimeout(r, 200)); 43 | 44 | const formattedName = `Invoice-${invoice.invoiceNumber}_${ 45 | invoice.buyerName 46 | }_${formatDateFilename(invoice?.invoiceDate).replaceAll("/", "")}.png` 47 | .replace(/\s+/g, "-") 48 | .toLowerCase(); 49 | 50 | await exportInvoiceToPng(hiddenRef.current, formattedName); 51 | } catch (error) { 52 | console.error("Download failed:", error); 53 | toast.error("Failed to export invoice"); 54 | } finally { 55 | setTimeout(() => setIsDownloading(false), 3000); 56 | } 57 | }; 58 | 59 | return ( 60 | <Dialog open={open} onOpenChange={onOpenChange}> 61 | <DialogHeader> 62 | <DialogTitle className="sr-only">Preview Invoice</DialogTitle> 63 | </DialogHeader> 64 | <DialogContent className="w-full md:max-w-7xl max-h-[90vh] overflow-y-auto p-0 mx-auto"> 65 | <div 66 | ref={hiddenRef} 67 | className="absolute -left-[9999px] top-0 bg-white p-2" 68 | style={{ 69 | width: "max-content", 70 | display: "inline-block", 71 | overflow: "visible", 72 | }} 73 | > 74 | <InvoicePreview 75 | invoice={invoice} 76 | invoiceItems={invoiceItems} 77 | onDataReady={setDataReady} 78 | oonReady={() => setIsInvoiceReady(true)} 79 | shippingType={shippingType} 80 | isDownloadVersion 81 | /> 82 | </div> 83 | 84 | <div className="flex flex-col md:flex-row gap-0"> 85 | {/* main content */} 86 | <div ref={invoiceRef} className="bg-white p-6 flex-1"> 87 | <InvoicePreview 88 | invoice={invoice} 89 | invoiceItems={invoiceItems} 90 | onDataReady={setDataReady} 91 | shippingType={shippingType} 92 | onReady={() => setIsInvoiceReady(true)} 93 | /> 94 | </div> 95 | 96 | {/* sidebar */} 97 | <div className="md:w-64 bg-white pt-5 flex flex-col items-center md:items-start text-center md:text-left"> 98 | <p className="mb-2 font-medium">Pilih Ongkir</p> 99 | <RadioGroup 100 | value={shippingType} 101 | onValueChange={setShippingType} 102 | className="flex flex-col gap-3" 103 | > 104 | {["", "sameday", "instan", "jne", "j&t"].map((value) => ( 105 | <div key={value} className="flex items-center space-x-2"> 106 | <RadioGroupItem value={value} id={value || "default"} /> 107 | <Label htmlFor={value || "default"} className="text-xs"> 108 | {value === "" ? "Default (tanpa opsi)" : value} 109 | </Label> 110 | </div> 111 | ))} 112 | </RadioGroup> 113 | </div> 114 | </div> 115 | 116 | {/* desktop view */} 117 | <div className="hidden md:block sticky bottom-0 z-10 bg-white p-4 border-t border-gray-200 shadow-sm text-center"> 118 | <Button onClick={handleDownload} disabled={isDownloading}> 119 | {isDownloading ? "Please wait ..." : "Download PNG"} 120 | </Button> 121 | </div> 122 | 123 | {/* mobile view */} 124 | <div className="md:hidden sticky bottom-0 z-10 bg-white p-4 border-t border-gray-200"> 125 | <Button className="w-full" onClick={handleDownload} disabled={isDownloading}> 126 | {isDownloading ? "Please wait ..." : "Download PNG"} 127 | </Button> 128 | </div> 129 | </DialogContent> 130 | </Dialog> 131 | ); 132 | } 133 | ``` -------------------------------------------------------------------------------- /src/components/ui/command.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Command as CommandPrimitive } from "cmdk" 5 | import { SearchIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | import { 9 | Dialog, 10 | DialogContent, 11 | DialogDescription, 12 | DialogHeader, 13 | DialogTitle, 14 | } from "@/components/ui/dialog" 15 | 16 | function Command({ 17 | className, 18 | ...props 19 | }) { 20 | return ( 21 | <CommandPrimitive 22 | data-slot="command" 23 | className={cn( 24 | "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md", 25 | className 26 | )} 27 | {...props} /> 28 | ); 29 | } 30 | 31 | function CommandDialog({ 32 | title = "Command Palette", 33 | description = "Search for a command to run...", 34 | children, 35 | className, 36 | showCloseButton = true, 37 | ...props 38 | }) { 39 | return ( 40 | <Dialog {...props}> 41 | <DialogHeader className="sr-only"> 42 | <DialogTitle>{title}</DialogTitle> 43 | <DialogDescription>{description}</DialogDescription> 44 | </DialogHeader> 45 | <DialogContent 46 | className={cn("overflow-hidden p-0", className)} 47 | showCloseButton={showCloseButton}> 48 | <Command 49 | 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"> 50 | {children} 51 | </Command> 52 | </DialogContent> 53 | </Dialog> 54 | ); 55 | } 56 | 57 | function CommandInput({ 58 | className, 59 | ...props 60 | }) { 61 | return ( 62 | <div 63 | data-slot="command-input-wrapper" 64 | className="flex h-9 items-center gap-2 border-b px-3"> 65 | <SearchIcon className="size-4 shrink-0 opacity-50" /> 66 | <CommandPrimitive.Input 67 | data-slot="command-input" 68 | className={cn( 69 | "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", 70 | className 71 | )} 72 | {...props} /> 73 | </div> 74 | ); 75 | } 76 | 77 | function CommandList({ 78 | className, 79 | ...props 80 | }) { 81 | return ( 82 | <CommandPrimitive.List 83 | data-slot="command-list" 84 | className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)} 85 | {...props} /> 86 | ); 87 | } 88 | 89 | function CommandEmpty({ 90 | ...props 91 | }) { 92 | return (<CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />); 93 | } 94 | 95 | function CommandGroup({ 96 | className, 97 | ...props 98 | }) { 99 | return ( 100 | <CommandPrimitive.Group 101 | data-slot="command-group" 102 | className={cn( 103 | "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", 104 | className 105 | )} 106 | {...props} /> 107 | ); 108 | } 109 | 110 | function CommandSeparator({ 111 | className, 112 | ...props 113 | }) { 114 | return ( 115 | <CommandPrimitive.Separator 116 | data-slot="command-separator" 117 | className={cn("bg-border -mx-1 h-px", className)} 118 | {...props} /> 119 | ); 120 | } 121 | 122 | function CommandItem({ 123 | className, 124 | ...props 125 | }) { 126 | return ( 127 | <CommandPrimitive.Item 128 | data-slot="command-item" 129 | className={cn( 130 | "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", 131 | className 132 | )} 133 | {...props} /> 134 | ); 135 | } 136 | 137 | function CommandShortcut({ 138 | className, 139 | ...props 140 | }) { 141 | return ( 142 | <span 143 | data-slot="command-shortcut" 144 | className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)} 145 | {...props} /> 146 | ); 147 | } 148 | 149 | export { 150 | Command, 151 | CommandDialog, 152 | CommandInput, 153 | CommandList, 154 | CommandEmpty, 155 | CommandGroup, 156 | CommandItem, 157 | CommandShortcut, 158 | CommandSeparator, 159 | } 160 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/size-pricing/_components/Table.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useState, useEffect, forwardRef, useImperativeHandle } from "react"; 4 | 5 | import { Card } from "@/components/ui/card"; 6 | import { Button } from "@/components/ui/button"; 7 | 8 | import EditSizeModal from "./EditSizeModal"; 9 | import DeleteSizeModal from "./DeleteSizeModal"; 10 | 11 | import { ArrowUp, ArrowDown, Pencil, Trash2 } from "lucide-react"; 12 | 13 | import { getAllSizePrice } from "@/lib/actions/size-price/getAll"; 14 | 15 | const ITEMS_PER_PAGE = 10; 16 | 17 | const SizePriceTable = forwardRef(function SizePriceTable(props, ref) { 18 | const [size, setSize] = useState([]); 19 | const [sortOrder, setSortOrder] = useState("asc"); 20 | 21 | const [error, setError] = useState(null); 22 | const [currentPage, setCurrentPage] = useState(1); 23 | 24 | const [selectedSize, setSelectedSize] = useState(null); 25 | const [deleteModalOpen, setDeleteModalOpen] = useState(false); 26 | const [editModalOpen, setEditModalOpen] = useState(false); 27 | 28 | if (error) return <p className="text-red-500">Failed to fetch data: {error}</p>; 29 | 30 | const fetchData = async () => { 31 | const { data, error } = await getAllSizePrice(sortOrder); 32 | if (error) setError(error.message); 33 | else setSize(data); 34 | }; 35 | 36 | useEffect(() => { 37 | fetchData(); 38 | }, [sortOrder]); 39 | 40 | useImperativeHandle(ref, () => ({ 41 | refetch: fetchData, 42 | })); 43 | 44 | // pagination 45 | const totalPages = Math.ceil(size.length / ITEMS_PER_PAGE); 46 | const paginatedData = size.slice( 47 | (currentPage - 1) * ITEMS_PER_PAGE, 48 | currentPage * ITEMS_PER_PAGE 49 | ); 50 | 51 | return ( 52 | <Card className="p-4 bg-[#fffaf0] border border-[#f4e3d3] shadow-sm"> 53 | <div className="overflow-x-auto rounded-lg border border-[#fceee4]"> 54 | <table className="w-full text-sm"> 55 | <thead className="bg-[#fdf2e9] text-[#6D2315]"> 56 | <tr> 57 | <th 58 | className="px-4 py-2 text-left font-semibold cursor-pointer" 59 | onClick={() => { 60 | setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); 61 | setCurrentPage(1); // reset ke page 1 tiap kali sorting 62 | }} 63 | > 64 | <div className="flex items-center gap-1"> 65 | Size 66 | {sortOrder === "asc" ? ( 67 | <ArrowUp className="w-4 h-4" /> 68 | ) : ( 69 | <ArrowDown className="w-4 h-4" /> 70 | )} 71 | </div> 72 | </th> 73 | 74 | <th className="px-4 py-2 text-left font-semibold">Price</th> 75 | <th className="px-4 py-2 text-left font-semibold">Created At</th> 76 | <th className="px-4 py-2 text-left font-semibold">Action</th> 77 | </tr> 78 | </thead> 79 | <tbody> 80 | {paginatedData.map((data) => ( 81 | <tr 82 | key={data.id} 83 | className="border-t border-[#fceee4] hover:bg-[#fff3ec] transition-colors" 84 | > 85 | <td className="px-4 py-2 text-gray-800">{data.size}</td> 86 | <td className="px-4 py-2 text-gray-800"> 87 | Rp. {(data.price || 0).toLocaleString("id-ID")} 88 | </td> 89 | <td className="px-4 py-2 text-gray-800"> 90 | {new Date(data.createdAt).toLocaleString()} 91 | </td> 92 | <td className="px-4 py-2"> 93 | <div className="flex gap-2"> 94 | <Button 95 | onClick={() => { 96 | setSelectedSize(data); 97 | setEditModalOpen(true); 98 | }} 99 | variant="ghost" 100 | size="icon" 101 | className="text-blue-500 hover:text-blue-600" 102 | > 103 | <Pencil className="h-4 w-4" /> 104 | </Button> 105 | <Button 106 | onClick={() => { 107 | setSelectedSize(data); 108 | setDeleteModalOpen(true); 109 | }} 110 | variant="ghost" 111 | size="icon" 112 | className="text-red-500 hover:text-red-600" 113 | > 114 | <Trash2 className="h-4 w-4" /> 115 | </Button> 116 | </div> 117 | </td> 118 | </tr> 119 | ))} 120 | </tbody> 121 | </table> 122 | </div> 123 | 124 | {/* Pagination */} 125 | <div className="mt-4 flex justify-end flex-wrap gap-2"> 126 | {Array.from({ length: totalPages }).map((_, idx) => { 127 | const page = idx + 1; 128 | return ( 129 | <Button 130 | key={page} 131 | onClick={() => setCurrentPage(page)} 132 | variant={page === currentPage ? "default" : "outline"} 133 | size="sm" 134 | className={page === currentPage ? "bg-[#6D2315] text-white" : ""} 135 | > 136 | {page} 137 | </Button> 138 | ); 139 | })} 140 | </div> 141 | 142 | <EditSizeModal 143 | open={editModalOpen} 144 | onOpenChange={setEditModalOpen} 145 | data={selectedSize} 146 | onSuccess={(updatedSize) => { 147 | setSize((prev) => prev.map((p) => (p.id === updatedSize.id ? updatedSize : p))); 148 | setSelectedSize(null); 149 | }} 150 | /> 151 | 152 | <DeleteSizeModal 153 | open={deleteModalOpen} 154 | onOpenChange={setDeleteModalOpen} 155 | sizeId={selectedSize?.id} 156 | onSuccess={() => { 157 | setSize((prev) => prev.filter((p) => p.id !== selectedSize?.id)); 158 | setSelectedSize(null); 159 | }} 160 | /> 161 | </Card> 162 | ); 163 | }); 164 | 165 | export default SizePriceTable; 166 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/products/_components/ProductTable.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useState, useEffect, forwardRef, useImperativeHandle } from "react"; 4 | 5 | import { Card } from "@/components/ui/card"; 6 | import { Button } from "@/components/ui/button"; 7 | import { Input } from "@/components/ui/input"; 8 | 9 | import ProductDeleteModal from "./ProductDeleteModal"; 10 | import ProductEditModal from "./ProductEditModal"; 11 | 12 | import { ArrowUp, ArrowDown, Pencil, Trash2 } from "lucide-react"; 13 | 14 | import { getAllProducts } from "@/lib/actions/products/getAllProducts"; 15 | 16 | const ITEMS_PER_PAGE = 10; 17 | 18 | const ProductTable = forwardRef(function ProductTable(props, ref) { 19 | const [products, setProducts] = useState([]); 20 | const [sortOrder, setSortOrder] = useState("asc"); 21 | 22 | const [error, setError] = useState(null); 23 | const [currentPage, setCurrentPage] = useState(1); 24 | 25 | const [selectedProduct, setSelectedProduct] = useState(null); 26 | const [deleteModalOpen, setDeleteModalOpen] = useState(false); 27 | const [editModalOpen, setEditModalOpen] = useState(false); 28 | 29 | const [searchQuery, setSearchQuery] = useState(""); 30 | 31 | if (error) return <p className="text-red-500">Failed to fetch data: {error}</p>; 32 | 33 | const fetchData = async () => { 34 | const { data, error } = await getAllProducts(sortOrder); 35 | if (error) setError(error.message); 36 | else setProducts(data); 37 | }; 38 | 39 | useEffect(() => { 40 | fetchData(); 41 | }, [sortOrder]); 42 | 43 | useImperativeHandle(ref, () => ({ 44 | refetch: fetchData, 45 | })); 46 | 47 | const filteredData = products.filter((product) => { 48 | const query = searchQuery.toLowerCase(); 49 | return product.name.toLowerCase().includes(query); 50 | }); 51 | 52 | // pagination 53 | const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE); 54 | const paginatedData = filteredData.slice( 55 | (currentPage - 1) * ITEMS_PER_PAGE, 56 | currentPage * ITEMS_PER_PAGE 57 | ); 58 | 59 | return ( 60 | <Card className="p-4 bg-[#fffaf0] border border-[#f4e3d3] shadow-sm"> 61 | {/* Search Field */} 62 | <Input 63 | type="text" 64 | placeholder="Search product..." 65 | value={searchQuery} 66 | onChange={(e) => { 67 | setSearchQuery(e.target.value); 68 | setCurrentPage(1); 69 | }} 70 | 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]" 71 | /> 72 | 73 | {/* Table */} 74 | <div className="overflow-x-auto rounded-lg border border-[#fceee4]"> 75 | <table className="w-full text-sm"> 76 | <thead className="bg-[#fdf2e9] text-[#6D2315]"> 77 | <tr> 78 | <th 79 | className="px-4 py-2 text-left font-semibold cursor-pointer" 80 | onClick={() => { 81 | setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); 82 | setCurrentPage(1); 83 | }} 84 | > 85 | <div className="flex items-center gap-1"> 86 | Product Name 87 | {sortOrder === "asc" ? ( 88 | <ArrowUp className="w-4 h-4" /> 89 | ) : ( 90 | <ArrowDown className="w-4 h-4" /> 91 | )} 92 | </div> 93 | </th> 94 | <th className="px-4 py-2 text-left font-semibold">Description</th> 95 | <th className="px-4 py-2 text-left font-semibold">Created At</th> 96 | <th className="px-4 py-2 text-left font-semibold">Action</th> 97 | </tr> 98 | </thead> 99 | 100 | <tbody> 101 | {paginatedData.map((product) => ( 102 | <tr 103 | key={product.id} 104 | className="border-t border-[#fceee4] hover:bg-[#fff3ec] transition-colors" 105 | > 106 | <td className="px-4 py-2 text-gray-800">{product.name}</td> 107 | <td className="px-4 py-2 text-gray-600">{product.description || "-"}</td> 108 | <td className="px-4 py-2 text-gray-500"> 109 | {new Date(product.createdAt).toLocaleString()} 110 | </td> 111 | <td className="px-4 py-2"> 112 | <div className="flex gap-2"> 113 | <Button 114 | onClick={() => { 115 | setSelectedProduct(product); 116 | setEditModalOpen(true); 117 | }} 118 | variant="ghost" 119 | size="icon" 120 | className="text-blue-500 hover:text-blue-600" 121 | > 122 | <Pencil className="h-4 w-4" /> 123 | </Button> 124 | <Button 125 | onClick={() => { 126 | setSelectedProduct(product); 127 | setDeleteModalOpen(true); 128 | }} 129 | variant="ghost" 130 | size="icon" 131 | className="text-red-500 hover:text-red-600" 132 | > 133 | <Trash2 className="h-4 w-4" /> 134 | </Button> 135 | </div> 136 | </td> 137 | </tr> 138 | ))} 139 | </tbody> 140 | </table> 141 | </div> 142 | 143 | {/* Pagination */} 144 | <div className="mt-4 flex justify-end flex-wrap gap-2"> 145 | {Array.from({ length: totalPages }).map((_, idx) => { 146 | const page = idx + 1; 147 | return ( 148 | <Button 149 | key={page} 150 | onClick={() => setCurrentPage(page)} 151 | variant={page === currentPage ? "default" : "outline"} 152 | size="sm" 153 | className={page === currentPage ? "bg-[#6D2315] text-white" : ""} 154 | > 155 | {page} 156 | </Button> 157 | ); 158 | })} 159 | </div> 160 | 161 | {/* Modals */} 162 | <ProductDeleteModal 163 | open={deleteModalOpen} 164 | onOpenChange={setDeleteModalOpen} 165 | productId={selectedProduct?.id} 166 | onSuccess={() => { 167 | setProducts((prev) => prev.filter((p) => p.id !== selectedProduct?.id)); 168 | setSelectedProduct(null); 169 | }} 170 | /> 171 | 172 | <ProductEditModal 173 | open={editModalOpen} 174 | onOpenChange={setEditModalOpen} 175 | product={selectedProduct} 176 | onSuccess={(updatedProduct) => { 177 | setProducts((prev) => prev.map((p) => (p.id === updatedProduct.id ? updatedProduct : p))); 178 | setSelectedProduct(null); 179 | }} 180 | /> 181 | </Card> 182 | ); 183 | }); 184 | 185 | export default ProductTable; 186 | ``` -------------------------------------------------------------------------------- /src/components/ui/select.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Select({ 10 | ...props 11 | }) { 12 | return <SelectPrimitive.Root data-slot="select" {...props} />; 13 | } 14 | 15 | function SelectGroup({ 16 | ...props 17 | }) { 18 | return <SelectPrimitive.Group data-slot="select-group" {...props} />; 19 | } 20 | 21 | function SelectValue({ 22 | ...props 23 | }) { 24 | return <SelectPrimitive.Value data-slot="select-value" {...props} />; 25 | } 26 | 27 | function SelectTrigger({ 28 | className, 29 | size = "default", 30 | children, 31 | ...props 32 | }) { 33 | return ( 34 | <SelectPrimitive.Trigger 35 | data-slot="select-trigger" 36 | data-size={size} 37 | className={cn( 38 | "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", 39 | className 40 | )} 41 | {...props}> 42 | {children} 43 | <SelectPrimitive.Icon asChild> 44 | <ChevronDownIcon className="size-4 opacity-50" /> 45 | </SelectPrimitive.Icon> 46 | </SelectPrimitive.Trigger> 47 | ); 48 | } 49 | 50 | function SelectContent({ 51 | className, 52 | children, 53 | position = "popper", 54 | ...props 55 | }) { 56 | return ( 57 | <SelectPrimitive.Portal> 58 | <SelectPrimitive.Content 59 | data-slot="select-content" 60 | className={cn( 61 | "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", 62 | position === "popper" && 63 | "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", 64 | className 65 | )} 66 | position={position} 67 | {...props}> 68 | <SelectScrollUpButton /> 69 | <SelectPrimitive.Viewport 70 | className={cn("p-1", position === "popper" && 71 | "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1")}> 72 | {children} 73 | </SelectPrimitive.Viewport> 74 | <SelectScrollDownButton /> 75 | </SelectPrimitive.Content> 76 | </SelectPrimitive.Portal> 77 | ); 78 | } 79 | 80 | function SelectLabel({ 81 | className, 82 | ...props 83 | }) { 84 | return ( 85 | <SelectPrimitive.Label 86 | data-slot="select-label" 87 | className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} 88 | {...props} /> 89 | ); 90 | } 91 | 92 | function SelectItem({ 93 | className, 94 | children, 95 | ...props 96 | }) { 97 | return ( 98 | <SelectPrimitive.Item 99 | data-slot="select-item" 100 | className={cn( 101 | "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", 102 | className 103 | )} 104 | {...props}> 105 | <span className="absolute right-2 flex size-3.5 items-center justify-center"> 106 | <SelectPrimitive.ItemIndicator> 107 | <CheckIcon className="size-4" /> 108 | </SelectPrimitive.ItemIndicator> 109 | </span> 110 | <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> 111 | </SelectPrimitive.Item> 112 | ); 113 | } 114 | 115 | function SelectSeparator({ 116 | className, 117 | ...props 118 | }) { 119 | return ( 120 | <SelectPrimitive.Separator 121 | data-slot="select-separator" 122 | className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} 123 | {...props} /> 124 | ); 125 | } 126 | 127 | function SelectScrollUpButton({ 128 | className, 129 | ...props 130 | }) { 131 | return ( 132 | <SelectPrimitive.ScrollUpButton 133 | data-slot="select-scroll-up-button" 134 | className={cn("flex cursor-default items-center justify-center py-1", className)} 135 | {...props}> 136 | <ChevronUpIcon className="size-4" /> 137 | </SelectPrimitive.ScrollUpButton> 138 | ); 139 | } 140 | 141 | function SelectScrollDownButton({ 142 | className, 143 | ...props 144 | }) { 145 | return ( 146 | <SelectPrimitive.ScrollDownButton 147 | data-slot="select-scroll-down-button" 148 | className={cn("flex cursor-default items-center justify-center py-1", className)} 149 | {...props}> 150 | <ChevronDownIcon className="size-4" /> 151 | </SelectPrimitive.ScrollDownButton> 152 | ); 153 | } 154 | 155 | export { 156 | Select, 157 | SelectContent, 158 | SelectGroup, 159 | SelectItem, 160 | SelectLabel, 161 | SelectScrollDownButton, 162 | SelectScrollUpButton, 163 | SelectSeparator, 164 | SelectTrigger, 165 | SelectValue, 166 | } 167 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/page.jsx: -------------------------------------------------------------------------------- ```javascript 1 | import Link from "next/link"; 2 | import { redirect, unauthorized } from "next/navigation"; 3 | 4 | import { supabaseServer } from "@/lib/supabaseServer"; 5 | 6 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 7 | import { Button } from "@/components/ui/button"; 8 | 9 | import { getPageTitle, getStatusVariant, toTitleCase } from "@/lib/utils"; 10 | import { AlertTriangle, CheckCircle, FileText, Package, Users, Wallet } from "lucide-react"; 11 | 12 | export const metadata = { 13 | title: getPageTitle("Dashboard"), 14 | }; 15 | 16 | export default async function Dashboard() { 17 | const supabase = await supabaseServer(); 18 | 19 | const { 20 | data: { session }, 21 | } = await supabase.auth.getSession(); 22 | 23 | if (!session) { 24 | unauthorized(); 25 | } 26 | 27 | // const user = session.user; 28 | 29 | // get all invoices 30 | const { data: invoices } = await supabase 31 | .from("Invoice") 32 | .select("*") 33 | .order("invoiceDate", { ascending: false }); 34 | 35 | // get total invoice 36 | const totalInvoices = invoices.length; 37 | 38 | // get latest invoice data 39 | const latestInvoices = invoices.slice(0, 5); 40 | 41 | // count paid invoices 42 | const invoicesSuccess = invoices.filter((inv) => inv.status === "success").length; 43 | 44 | // count unpaid invoices 45 | const invoicesUnpaid = invoices.filter((inv) => inv.status === "pending").length; 46 | 47 | // count total customers (unique) 48 | const { data } = await supabase.from("Invoice").select("buyerName"); 49 | const uniqueCustomers = new Set(data.map((d) => d.buyerName.trim().toLowerCase())); 50 | const totalCustomers = uniqueCustomers.size; 51 | 52 | const totalAmount = 53 | invoices 54 | ?.filter((inv) => inv.status === "success") 55 | .reduce((acc, curr) => acc + curr.totalPrice, 0) || 0; 56 | 57 | // get total products 58 | const { count: totalProducts } = await supabase 59 | .from("Product") 60 | .select("*", { count: "exact", head: true }); 61 | 62 | return ( 63 | <div className="grid gap-6"> 64 | {/* Welcome Card */} 65 | <Card className="bg-[#fffaf0] border border-[#f4e3d3] shadow-sm"> 66 | <CardHeader> 67 | <CardTitle className="text-xl text-[#6D2315] font-bold">Welcome back, Admin 👋</CardTitle> 68 | </CardHeader> 69 | <CardContent> 70 | <p className="text-sm text-gray-600">Manage your dashboard here.</p> 71 | <div className="mt-4"> 72 | <Link href="/dashboard/invoices/create"> 73 | <Button className="bg-[#6D2315] hover:bg-[#591c10] text-white"> 74 | + Create Invoice 75 | </Button> 76 | </Link> 77 | </div> 78 | </CardContent> 79 | </Card> 80 | 81 | {/* Statistik Ringkas */} 82 | <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> 83 | {/* Total Invoices */} 84 | <Card className="bg-[#fef6f3] border-0 shadow-sm text-[#6D2315]"> 85 | <CardHeader> 86 | <CardTitle className="text-sm font-medium flex items-center gap-2"> 87 | <FileText className="w-4 h-4 text-[#6D2315]" /> 88 | Total Invoices 89 | </CardTitle> 90 | </CardHeader> 91 | <CardContent> 92 | <p className="text-3xl font-bold">{totalInvoices || 0}</p> 93 | </CardContent> 94 | </Card> 95 | 96 | {/* Total Income */} 97 | <Card className="bg-[#fdf2e9] border-0 shadow-sm text-[#92400e]"> 98 | <CardHeader> 99 | <CardTitle className="text-sm font-medium flex items-center gap-2"> 100 | <Wallet className="w-4 h-4 text-[#92400e]" /> 101 | Total Income 102 | </CardTitle> 103 | </CardHeader> 104 | <CardContent> 105 | <p className="text-3xl font-bold">Rp {totalAmount.toLocaleString("id-ID")}</p> 106 | </CardContent> 107 | </Card> 108 | 109 | {/* Total Products */} 110 | <Card className="bg-[#fef9e7] border-0 shadow-sm text-[#92400e]"> 111 | <CardHeader> 112 | <CardTitle className="text-sm font-medium flex items-center gap-2"> 113 | <Package className="w-4 h-4 text-[#92400e]" /> 114 | Total Products 115 | </CardTitle> 116 | </CardHeader> 117 | <CardContent> 118 | <p className="text-3xl font-bold">{totalProducts}</p> 119 | </CardContent> 120 | </Card> 121 | 122 | {/* Paid Invoices */} 123 | <Card className="bg-[#f0f9f5] border-0 shadow-sm text-[#065f46]"> 124 | <CardHeader> 125 | <CardTitle className="text-sm font-medium flex items-center gap-2"> 126 | <CheckCircle className="w-4 h-4 text-[#065f46]" /> 127 | Paid Invoices 128 | </CardTitle> 129 | </CardHeader> 130 | <CardContent> 131 | <p className="text-3xl font-bold">{invoicesSuccess}</p> 132 | </CardContent> 133 | </Card> 134 | 135 | {/* Unpaid Invoices */} 136 | <Card className="bg-[#fef2f2] border-0 shadow-sm text-[#991b1b]"> 137 | <CardHeader> 138 | <CardTitle className="text-sm font-medium flex items-center gap-2"> 139 | <AlertTriangle className="w-4 h-4 text-[#991b1b]" /> 140 | Unpaid Invoices 141 | </CardTitle> 142 | </CardHeader> 143 | <CardContent> 144 | <p className="text-3xl font-bold">{invoicesUnpaid}</p> 145 | </CardContent> 146 | </Card> 147 | 148 | {/* Total Customers */} 149 | <Card className="bg-[#e8f0fe] border-0 shadow-sm text-[#1e3a8a]"> 150 | <CardHeader> 151 | <CardTitle className="text-sm font-medium flex items-center gap-2"> 152 | <Users className="w-4 h-4 text-[#1e3a8a]" /> 153 | Total Customers 154 | </CardTitle> 155 | </CardHeader> 156 | <CardContent> 157 | <p className="text-3xl font-bold">{totalCustomers}</p> 158 | </CardContent> 159 | </Card> 160 | </div> 161 | 162 | {/* Latest Invoices */} 163 | <Card className="bg-white border border-[#f4e3d3] shadow-sm"> 164 | <CardHeader> 165 | <CardTitle className="text-[#6D2315] font-medium">Latest Invoices</CardTitle> 166 | </CardHeader> 167 | 168 | <CardContent className="space-y-3"> 169 | {latestInvoices && latestInvoices.length > 0 ? ( 170 | latestInvoices.map((inv) => ( 171 | <div 172 | key={inv.id} 173 | className="flex items-center justify-between bg-[#fefaf7] hover:bg-[#fff3ec] transition-colors duration-150 border border-[#fceee4] rounded-md p-3" 174 | > 175 | {/* Left Section: Icon + Info */} 176 | <div className="flex items-center gap-3"> 177 | <div className="bg-[#fdf0e6] text-[#6D2315] p-2 rounded-md"> 178 | <FileText className="w-5 h-5" /> 179 | </div> 180 | <div> 181 | <p className="font-medium text-gray-800">Invoice #{inv.invoiceNumber}</p> 182 | <p className="text-sm text-gray-500">{toTitleCase(inv.buyerName)}</p> 183 | </div> 184 | </div> 185 | 186 | {/* Right Section: Amount + Status */} 187 | <div className="text-right space-y-1"> 188 | <p className="font-semibold text-gray-800"> 189 | Rp {inv.totalPrice.toLocaleString("id-ID")} 190 | </p> 191 | <span className={getStatusVariant(inv.status)}>{toTitleCase(inv.status)}</span> 192 | </div> 193 | </div> 194 | )) 195 | ) : ( 196 | <p className="text-sm text-gray-500">No invoice data.</p> 197 | )} 198 | </CardContent> 199 | </Card> 200 | </div> 201 | ); 202 | } 203 | ``` -------------------------------------------------------------------------------- /src/app/dashboard/invoices/Table.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client"; 2 | 3 | import { useState, useEffect, forwardRef, useImperativeHandle } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | 6 | import { Card } from "@/components/ui/card"; 7 | import { Button } from "@/components/ui/button"; 8 | import { Input } from "@/components/ui/input"; 9 | 10 | import DeleteInvoiceModal from "./_components/DeleteInvoiceModal"; 11 | import InvoiceDownloadModal from "./_components/InvoiceDownloadModal"; 12 | 13 | import { getAllInvoice } from "@/lib/actions/invoice/getAll"; 14 | import { getInvoiceWithItems } from "@/lib/actions/invoice/getInvoiceWithItem"; 15 | 16 | import { formatInvoiceDateTime, getStatusVariant, toTitleCase } from "@/lib/utils"; 17 | 18 | import { ArrowUp, ArrowDown, Pencil, Trash2, Download } from "lucide-react"; 19 | 20 | const ITEMS_PER_PAGE = 10; 21 | 22 | const InvoicesTable = forwardRef(function InvoicesTable(props, ref) { 23 | const router = useRouter(); 24 | 25 | const [invoice, setInvoice] = useState([]); 26 | const [sortOrder, setSortOrder] = useState("desc"); 27 | 28 | const [error, setError] = useState(null); 29 | const [currentPage, setCurrentPage] = useState(1); 30 | 31 | const [selectedInvoice, setSelectedInvoice] = useState(null); 32 | const [deleteModalOpen, setDeleteModalOpen] = useState(false); 33 | 34 | const [searchQuery, setSearchQuery] = useState(""); 35 | 36 | const [downloadModalOpen, setDownloadModalOpen] = useState(false); 37 | const [invoiceItems, setInvoiceItems] = useState([]); 38 | 39 | if (error) return <p className="text-red-500">Failed to fetch data: {error}</p>; 40 | 41 | const fetchData = async () => { 42 | const { data, error } = await getAllInvoice(sortOrder); 43 | if (error) setError(error.message); 44 | else setInvoice(data); 45 | }; 46 | 47 | useEffect(() => { 48 | fetchData(); 49 | }, [sortOrder]); 50 | 51 | useImperativeHandle(ref, () => ({ 52 | refetch: fetchData, 53 | })); 54 | 55 | const filteredData = invoice.filter((inv) => { 56 | const query = searchQuery.toLowerCase(); 57 | return ( 58 | inv.invoiceNumber.toLowerCase().includes(query) || inv.buyerName.toLowerCase().includes(query) 59 | ); 60 | }); 61 | 62 | // pagination 63 | const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE); 64 | const paginatedData = filteredData.slice( 65 | (currentPage - 1) * ITEMS_PER_PAGE, 66 | currentPage * ITEMS_PER_PAGE 67 | ); 68 | 69 | const handleDownload = async (invoiceId) => { 70 | const { invoice, items } = await getInvoiceWithItems(invoiceId); 71 | setSelectedInvoice(invoice); 72 | setInvoiceItems(items); 73 | setDownloadModalOpen(true); 74 | }; 75 | 76 | return ( 77 | <Card className="p-4 bg-[#fffaf0] border border-[#f4e3d3] shadow-sm"> 78 | <Input 79 | type="text" 80 | placeholder="Search by name/inv.number" 81 | value={searchQuery} 82 | onChange={(e) => { 83 | setSearchQuery(e.target.value); 84 | setCurrentPage(1); 85 | }} 86 | 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]" 87 | /> 88 | <div className="overflow-x-auto rounded-lg border border-[#fceee4]"> 89 | <table className="w-full text-sm"> 90 | <thead className="bg-[#fdf2e9] text-[#6D2315]"> 91 | <tr> 92 | <th 93 | className="px-4 py-2 text-left font-semibold cursor-pointer" 94 | onClick={() => { 95 | setSortOrder((prev) => (prev === "asc" ? "desc" : "asc")); 96 | setCurrentPage(1); // reset ke page 1 tiap kali sorting 97 | }} 98 | > 99 | <div className="flex items-center gap-1"> 100 | Invoice Number 101 | {sortOrder === "desc" ? ( 102 | <ArrowDown className="w-4 h-4" /> 103 | ) : ( 104 | <ArrowUp className="w-4 h-4" /> 105 | )} 106 | </div> 107 | </th> 108 | 109 | <th className="px-4 py-2 text-left font-semibold">Customer</th> 110 | <th className="px-4 py-2 text-left font-semibold">Total Price</th> 111 | <th className="px-4 py-2 text-left font-semibold">Date</th> 112 | <th className="px-4 py-2 text-left font-semibold">Status</th> 113 | <th className="px-4 py-2 text-left font-semibold">Action</th> 114 | </tr> 115 | </thead> 116 | <tbody> 117 | {paginatedData && paginatedData.length > 0 ? ( 118 | paginatedData.map((data) => ( 119 | <tr 120 | key={data.id} 121 | className="border-t border-[#fceee4] hover:bg-[#fff3ec] transition-colors" 122 | > 123 | <td className="px-4 py-2 text-gray-800">{data.invoiceNumber}</td> 124 | <td className="px-4 py-2 text-gray-800">{toTitleCase(data.buyerName)}</td> 125 | <td className="px-4 py-2 text-gray-800"> 126 | Rp. {(data.totalPrice || 0).toLocaleString("id-ID")} 127 | </td> 128 | <td className="px-4 py-2 text-gray-800"> 129 | {formatInvoiceDateTime(data.invoiceDate, data.createdAt)} 130 | </td> 131 | <td className="px-4 py-2 text-gray-800"> 132 | <span className={getStatusVariant(data.status)}> 133 | {toTitleCase(data.status)} 134 | </span> 135 | </td> 136 | <td className="px-4 py-2"> 137 | <div className="flex gap-2"> 138 | <Button 139 | onClick={() => { 140 | router.push(`/dashboard/invoices/${data.invoiceNumber}`); 141 | }} 142 | variant="ghost" 143 | size="icon" 144 | className="text-blue-500 hover:text-blue-600" 145 | > 146 | <Pencil className="h-4 w-4" /> 147 | </Button> 148 | <Button 149 | onClick={() => { 150 | setSelectedInvoice(data); 151 | setDeleteModalOpen(true); 152 | }} 153 | variant="ghost" 154 | size="icon" 155 | className="text-red-500 hover:text-red-600" 156 | > 157 | <Trash2 className="h-4 w-4" /> 158 | </Button> 159 | <Button 160 | onClick={() => handleDownload(data.id)} 161 | variant="ghost" 162 | size="icon" 163 | className="text-green-500 hover:text-green-600" 164 | > 165 | <Download className="h-4 w-4" /> 166 | </Button> 167 | </div> 168 | </td> 169 | </tr> 170 | )) 171 | ) : ( 172 | <tr> 173 | <td colSpan="6" className="px-4 py-6 text-center text-gray-500"> 174 | No invoice data 175 | </td> 176 | </tr> 177 | )} 178 | </tbody> 179 | </table> 180 | </div> 181 | 182 | {/* Pagination */} 183 | <div className="mt-4 flex justify-end flex-wrap gap-2"> 184 | {Array.from({ length: totalPages }).map((_, idx) => { 185 | const page = idx + 1; 186 | return ( 187 | <Button 188 | key={page} 189 | onClick={() => setCurrentPage(page)} 190 | variant={page === currentPage ? "default" : "outline"} 191 | size="sm" 192 | className={page === currentPage ? "bg-[#6D2315] hover:bg-[#8d2e1c] text-white" : ""} 193 | > 194 | {page} 195 | </Button> 196 | ); 197 | })} 198 | </div> 199 | 200 | <DeleteInvoiceModal 201 | open={deleteModalOpen} 202 | onOpenChange={setDeleteModalOpen} 203 | invoiceId={selectedInvoice?.id} 204 | onSuccess={() => { 205 | setInvoice((prev) => prev.filter((p) => p.id !== selectedInvoice?.id)); 206 | setSelectedInvoice(null); 207 | }} 208 | /> 209 | 210 | <InvoiceDownloadModal 211 | open={downloadModalOpen} 212 | onOpenChange={setDownloadModalOpen} 213 | invoice={selectedInvoice} 214 | invoiceItems={invoiceItems} 215 | /> 216 | </Card> 217 | ); 218 | }); 219 | 220 | export default InvoicesTable; 221 | ``` -------------------------------------------------------------------------------- /src/components/ui/calendar.jsx: -------------------------------------------------------------------------------- ```javascript 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { 5 | ChevronDownIcon, 6 | ChevronLeftIcon, 7 | ChevronRightIcon, 8 | } from "lucide-react" 9 | import { DayPicker, getDefaultClassNames } from "react-day-picker"; 10 | 11 | import { cn } from "@/lib/utils" 12 | import { Button, buttonVariants } from "@/components/ui/button" 13 | 14 | function Calendar({ 15 | className, 16 | classNames, 17 | showOutsideDays = true, 18 | captionLayout = "label", 19 | buttonVariant = "ghost", 20 | formatters, 21 | components, 22 | ...props 23 | }) { 24 | const defaultClassNames = getDefaultClassNames() 25 | 26 | return ( 27 | <DayPicker 28 | showOutsideDays={showOutsideDays} 29 | className={cn( 30 | "bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent", 31 | String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`, 32 | String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, 33 | className 34 | )} 35 | captionLayout={captionLayout} 36 | formatters={{ 37 | formatMonthDropdown: (date) => 38 | date.toLocaleString("default", { month: "short" }), 39 | ...formatters, 40 | }} 41 | classNames={{ 42 | root: cn("w-fit", defaultClassNames.root), 43 | months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months), 44 | month: cn("flex flex-col w-full gap-4", defaultClassNames.month), 45 | nav: cn( 46 | "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", 47 | defaultClassNames.nav 48 | ), 49 | button_previous: cn( 50 | buttonVariants({ variant: buttonVariant }), 51 | "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", 52 | defaultClassNames.button_previous 53 | ), 54 | button_next: cn( 55 | buttonVariants({ variant: buttonVariant }), 56 | "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none", 57 | defaultClassNames.button_next 58 | ), 59 | month_caption: cn( 60 | "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)", 61 | defaultClassNames.month_caption 62 | ), 63 | dropdowns: cn( 64 | "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5", 65 | defaultClassNames.dropdowns 66 | ), 67 | dropdown_root: cn( 68 | "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", 69 | defaultClassNames.dropdown_root 70 | ), 71 | dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown), 72 | caption_label: cn("select-none font-medium", captionLayout === "label" 73 | ? "text-sm" 74 | : "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), 75 | table: "w-full border-collapse", 76 | weekdays: cn("flex", defaultClassNames.weekdays), 77 | weekday: cn( 78 | "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", 79 | defaultClassNames.weekday 80 | ), 81 | week: cn("flex w-full mt-2", defaultClassNames.week), 82 | week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header), 83 | week_number: cn( 84 | "text-[0.8rem] select-none text-muted-foreground", 85 | defaultClassNames.week_number 86 | ), 87 | day: cn( 88 | "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", 89 | defaultClassNames.day 90 | ), 91 | range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start), 92 | range_middle: cn("rounded-none", defaultClassNames.range_middle), 93 | range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), 94 | today: cn( 95 | "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", 96 | defaultClassNames.today 97 | ), 98 | outside: cn( 99 | "text-muted-foreground aria-selected:text-muted-foreground", 100 | defaultClassNames.outside 101 | ), 102 | disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled), 103 | hidden: cn("invisible", defaultClassNames.hidden), 104 | ...classNames, 105 | }} 106 | components={{ 107 | Root: ({ className, rootRef, ...props }) => { 108 | return (<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />); 109 | }, 110 | Chevron: ({ className, orientation, ...props }) => { 111 | if (orientation === "left") { 112 | return (<ChevronLeftIcon className={cn("size-4", className)} {...props} />); 113 | } 114 | 115 | if (orientation === "right") { 116 | return (<ChevronRightIcon className={cn("size-4", className)} {...props} />); 117 | } 118 | 119 | return (<ChevronDownIcon className={cn("size-4", className)} {...props} />); 120 | }, 121 | DayButton: CalendarDayButton, 122 | WeekNumber: ({ children, ...props }) => { 123 | return ( 124 | <td {...props}> 125 | <div 126 | className="flex size-(--cell-size) items-center justify-center text-center"> 127 | {children} 128 | </div> 129 | </td> 130 | ); 131 | }, 132 | ...components, 133 | }} 134 | {...props} /> 135 | ); 136 | } 137 | 138 | function CalendarDayButton({ 139 | className, 140 | day, 141 | modifiers, 142 | ...props 143 | }) { 144 | const defaultClassNames = getDefaultClassNames() 145 | 146 | const ref = React.useRef(null) 147 | React.useEffect(() => { 148 | if (modifiers.focused) ref.current?.focus() 149 | }, [modifiers.focused]) 150 | 151 | return ( 152 | <Button 153 | ref={ref} 154 | variant="ghost" 155 | size="icon" 156 | data-day={day.date.toLocaleDateString()} 157 | data-selected-single={ 158 | modifiers.selected && 159 | !modifiers.range_start && 160 | !modifiers.range_end && 161 | !modifiers.range_middle 162 | } 163 | data-range-start={modifiers.range_start} 164 | data-range-end={modifiers.range_end} 165 | data-range-middle={modifiers.range_middle} 166 | className={cn( 167 | "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", 168 | defaultClassNames.day, 169 | className 170 | )} 171 | {...props} /> 172 | ); 173 | } 174 | 175 | export { Calendar, CalendarDayButton } 176 | ```