From 8b5a2e8056d6876efcdb7b8c4d0195c99bdfe2fa Mon Sep 17 00:00:00 2001 From: Vasily Zubarev Date: Wed, 7 May 2025 14:53:13 +0200 Subject: [PATCH] feat: invoice generator --- ai/attachments.ts | 2 +- app/(app)/apps/common.ts | 27 + app/(app)/apps/invoices/actions.ts | 126 ++++ .../invoices/components/invoice-generator.tsx | 278 +++++++++ .../apps/invoices/components/invoice-page.tsx | 584 ++++++++++++++++++ .../apps/invoices/components/invoice-pdf.tsx | 403 ++++++++++++ app/(app)/apps/invoices/default-templates.ts | 71 +++ app/(app)/apps/invoices/manifest.ts | 7 + app/(app)/apps/invoices/page.tsx | 31 + app/(app)/apps/layout.tsx | 3 + app/(app)/apps/page.tsx | 35 ++ app/(app)/export/transactions/route.ts | 2 +- app/(app)/files/actions.ts | 16 +- app/(app)/files/download/[fileId]/route.ts | 2 +- app/(app)/files/preview/[fileId]/route.ts | 2 +- app/(app)/files/static/[filename]/route.ts | 35 ++ app/(app)/layout.tsx | 1 + app/(app)/settings/actions.ts | 45 +- app/(app)/settings/backups/actions.ts | 8 +- app/(app)/settings/backups/data/route.ts | 2 +- app/(app)/settings/business/page.tsx | 14 + app/(app)/settings/layout.tsx | 4 + .../transactions/[transactionId]/page.tsx | 1 - app/(app)/transactions/actions.ts | 15 +- app/(app)/unsorted/actions.ts | 10 +- app/globals.css | 6 + components/export/transactions.tsx | 9 +- components/forms/select-currency.tsx | 4 +- components/forms/simple.tsx | 101 ++- .../settings/business-settings-form.tsx | 61 ++ components/settings/profile-settings-form.tsx | 6 +- components/sidebar/sidebar-user.tsx | 46 +- components/sidebar/sidebar.tsx | 15 +- forms/users.ts | 6 +- lib/auth.ts | 3 +- lib/config.ts | 12 + lib/files.ts | 37 +- lib/previews/images.ts | 18 +- lib/previews/pdf.ts | 23 +- lib/uploads.ts | 60 ++ lib/utils.ts | 42 +- models/apps.ts | 18 + package-lock.json | 500 ++++++++++++++- package.json | 3 + .../migration.sql | 5 + .../20250507100532_add_app_data/migration.sql | 15 + prisma/schema.prisma | 16 + public/fonts/Inter/Inter-Black.otf | Bin 0 -> 227788 bytes public/fonts/Inter/Inter-BlackItalic.otf | Bin 0 -> 236664 bytes public/fonts/Inter/Inter-Bold.otf | Bin 0 -> 232056 bytes public/fonts/Inter/Inter-BoldItalic.otf | Bin 0 -> 240756 bytes public/fonts/Inter/Inter-ExtraBold.otf | Bin 0 -> 232716 bytes public/fonts/Inter/Inter-ExtraBoldItalic.otf | Bin 0 -> 241052 bytes public/fonts/Inter/Inter-Italic.otf | Bin 0 -> 235808 bytes public/fonts/Inter/Inter-Medium.otf | Bin 0 -> 230788 bytes public/fonts/Inter/Inter-MediumItalic.otf | Bin 0 -> 240248 bytes public/fonts/Inter/Inter-Regular.otf | Bin 0 -> 223164 bytes public/fonts/Inter/Inter-SemiBold.otf | Bin 0 -> 231416 bytes public/fonts/Inter/Inter-SemiBoldItalic.otf | Bin 0 -> 240564 bytes 59 files changed, 2606 insertions(+), 124 deletions(-) create mode 100644 app/(app)/apps/common.ts create mode 100644 app/(app)/apps/invoices/actions.ts create mode 100644 app/(app)/apps/invoices/components/invoice-generator.tsx create mode 100644 app/(app)/apps/invoices/components/invoice-page.tsx create mode 100644 app/(app)/apps/invoices/components/invoice-pdf.tsx create mode 100644 app/(app)/apps/invoices/default-templates.ts create mode 100644 app/(app)/apps/invoices/manifest.ts create mode 100644 app/(app)/apps/invoices/page.tsx create mode 100644 app/(app)/apps/layout.tsx create mode 100644 app/(app)/apps/page.tsx create mode 100644 app/(app)/files/static/[filename]/route.ts create mode 100644 app/(app)/settings/business/page.tsx create mode 100644 components/settings/business-settings-form.tsx create mode 100644 lib/uploads.ts create mode 100644 models/apps.ts create mode 100644 prisma/migrations/20250505101845_add_business_details/migration.sql create mode 100644 prisma/migrations/20250507100532_add_app_data/migration.sql create mode 100644 public/fonts/Inter/Inter-Black.otf create mode 100644 public/fonts/Inter/Inter-BlackItalic.otf create mode 100644 public/fonts/Inter/Inter-Bold.otf create mode 100644 public/fonts/Inter/Inter-BoldItalic.otf create mode 100644 public/fonts/Inter/Inter-ExtraBold.otf create mode 100644 public/fonts/Inter/Inter-ExtraBoldItalic.otf create mode 100644 public/fonts/Inter/Inter-Italic.otf create mode 100644 public/fonts/Inter/Inter-Medium.otf create mode 100644 public/fonts/Inter/Inter-MediumItalic.otf create mode 100644 public/fonts/Inter/Inter-Regular.otf create mode 100644 public/fonts/Inter/Inter-SemiBold.otf create mode 100644 public/fonts/Inter/Inter-SemiBoldItalic.otf diff --git a/ai/attachments.ts b/ai/attachments.ts index d04dc9e..a5e584a 100644 --- a/ai/attachments.ts +++ b/ai/attachments.ts @@ -12,7 +12,7 @@ export type AnalyzeAttachment = { } export const loadAttachmentsForAI = async (user: User, file: File): Promise => { - const fullFilePath = await fullPathForFile(user, file) + const fullFilePath = fullPathForFile(user, file) const isFileExists = await fileExists(fullFilePath) if (!isFileExists) { throw new Error("File not found on disk") diff --git a/app/(app)/apps/common.ts b/app/(app)/apps/common.ts new file mode 100644 index 0000000..447d0af --- /dev/null +++ b/app/(app)/apps/common.ts @@ -0,0 +1,27 @@ +import fs from "fs/promises" +import path from "path" + +export type AppManifest = { + name: string + description: string + icon: string +} + +export async function getApps(): Promise<{ id: string; manifest: AppManifest }[]> { + const appsDir = path.join(process.cwd(), "app/(app)/apps") + const items = await fs.readdir(appsDir, { withFileTypes: true }) + + const apps = await Promise.all( + items + .filter((item) => item.isDirectory() && item.name !== "apps") + .map(async (item) => { + const manifestModule = await import(`./${item.name}/manifest`) + return { + id: item.name, + manifest: manifestModule.manifest as AppManifest, + } + }) + ) + + return apps +} diff --git a/app/(app)/apps/invoices/actions.ts b/app/(app)/apps/invoices/actions.ts new file mode 100644 index 0000000..a65948f --- /dev/null +++ b/app/(app)/apps/invoices/actions.ts @@ -0,0 +1,126 @@ +"use server" + +import { getCurrentUser, isSubscriptionExpired } from "@/lib/auth" +import { + getTransactionFileUploadPath, + getUserUploadsDirectory, + isEnoughStorageToUploadFile, + safePathJoin, +} from "@/lib/files" +import { getAppData, setAppData } from "@/models/apps" +import { createFile } from "@/models/files" +import { createTransaction, updateTransactionFiles } from "@/models/transactions" +import { Transaction, User } from "@/prisma/client" +import { renderToBuffer } from "@react-pdf/renderer" +import { randomUUID } from "crypto" +import { mkdir, writeFile } from "fs/promises" +import { revalidatePath } from "next/cache" +import path from "path" +import { createElement } from "react" +import { InvoiceFormData } from "./components/invoice-page" +import { InvoicePDF } from "./components/invoice-pdf" +import { InvoiceTemplate } from "./default-templates" +import { InvoiceAppData } from "./page" + +export async function generateInvoicePDF(data: InvoiceFormData): Promise { + const pdfElement = createElement(InvoicePDF, { data }) + const buffer = await renderToBuffer(pdfElement as any) + return new Uint8Array(buffer) +} + +export async function addNewTemplateAction(user: User, template: InvoiceTemplate) { + const appData = (await getAppData(user, "invoices")) as InvoiceAppData | null + const updatedTemplates = [...(appData?.templates || []), template] + const appDataResult = await setAppData(user, "invoices", { ...appData, templates: updatedTemplates }) + return { success: true, data: appDataResult } +} + +export async function deleteTemplateAction(user: User, templateId: string) { + const appData = (await getAppData(user, "invoices")) as InvoiceAppData | null + if (!appData) return { success: false, error: "No app data found" } + + const updatedTemplates = appData.templates.filter((t) => t.id !== templateId) + const appDataResult = await setAppData(user, "invoices", { ...appData, templates: updatedTemplates }) + return { success: true, data: appDataResult } +} + +export async function saveInvoiceAsTransactionAction( + formData: InvoiceFormData +): Promise<{ success: boolean; error?: string; data?: Transaction }> { + try { + const user = await getCurrentUser() + + // Generate PDF + const pdfBuffer = await generateInvoicePDF(formData) + + // Calculate total amount from items + const subtotal = formData.items.reduce((sum, item) => sum + item.subtotal, 0) + const taxes = formData.additionalTaxes.reduce((sum, tax) => sum + tax.amount, 0) + const fees = formData.additionalFees.reduce((sum, fee) => sum + fee.amount, 0) + const totalAmount = (formData.taxIncluded ? subtotal : subtotal + taxes) + fees + + // Create transaction + const transaction = await createTransaction(user.id, { + name: `Invoice #${formData.invoiceNumber || "unknown"}`, + merchant: `${formData.billTo.split("\n")[0]}`, + total: totalAmount * 100, + currencyCode: formData.currency, + issuedAt: new Date(formData.date), + categoryCode: null, + projectCode: null, + type: "income", + status: "pending", + }) + + // Check storage limits + if (!isEnoughStorageToUploadFile(user, pdfBuffer.length)) { + return { + success: false, + error: "Insufficient storage to save invoice PDF", + } + } + + if (isSubscriptionExpired(user)) { + return { + success: false, + error: "Your subscription has expired, please upgrade your account or buy new subscription plan", + } + } + + // Save PDF file + const fileUuid = randomUUID() + const fileName = `invoice-${formData.invoiceNumber}.pdf` + const relativeFilePath = getTransactionFileUploadPath(fileUuid, fileName, transaction) + const userUploadsDirectory = getUserUploadsDirectory(user) + const fullFilePath = safePathJoin(userUploadsDirectory, relativeFilePath) + + await mkdir(path.dirname(fullFilePath), { recursive: true }) + await writeFile(fullFilePath, pdfBuffer) + + // Create file record in database + const fileRecord = await createFile(user.id, { + id: fileUuid, + filename: fileName, + path: relativeFilePath, + mimetype: "application/pdf", + isReviewed: true, + metadata: { + size: pdfBuffer.length, + lastModified: Date.now(), + }, + }) + + // Update transaction with the file ID + await updateTransactionFiles(transaction.id, user.id, [fileRecord.id]) + + revalidatePath("/transactions") + + return { success: true, data: transaction } + } catch (error) { + console.error("Failed to save invoice as transaction:", error) + return { + success: false, + error: `Failed to save invoice as transaction: ${error}`, + } + } +} diff --git a/app/(app)/apps/invoices/components/invoice-generator.tsx b/app/(app)/apps/invoices/components/invoice-generator.tsx new file mode 100644 index 0000000..4650ff8 --- /dev/null +++ b/app/(app)/apps/invoices/components/invoice-generator.tsx @@ -0,0 +1,278 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { fetchAsBase64 } from "@/lib/utils" +import { SettingsMap } from "@/models/settings" +import { Currency, User } from "@/prisma/client" +import { FileDown, Save, TextSelect, X } from "lucide-react" +import { useRouter } from "next/navigation" +import { startTransition, useMemo, useReducer, useState } from "react" +import { + addNewTemplateAction, + deleteTemplateAction, + generateInvoicePDF, + saveInvoiceAsTransactionAction, +} from "../actions" +import defaultTemplates, { InvoiceTemplate } from "../default-templates" +import { InvoiceAppData } from "../page" +import { InvoiceFormData, InvoicePage } from "./invoice-page" + +function invoiceFormReducer(state: InvoiceFormData, action: any): InvoiceFormData { + switch (action.type) { + case "SET_FORM": + return action.payload + case "UPDATE_FIELD": + return { ...state, [action.field]: action.value } + case "ADD_ITEM": + return { ...state, items: [...state.items, { description: "", quantity: 1, unitPrice: 0, subtotal: 0 }] } + case "UPDATE_ITEM": { + const items = [...state.items] + items[action.index] = { ...items[action.index], [action.field]: action.value } + if (action.field === "quantity" || action.field === "unitPrice") { + items[action.index].subtotal = Number(items[action.index].quantity) * Number(items[action.index].unitPrice) + } + return { ...state, items } + } + case "REMOVE_ITEM": + return { ...state, items: state.items.filter((_, i) => i !== action.index) } + case "ADD_TAX": + return { ...state, additionalTaxes: [...state.additionalTaxes, { name: "", rate: 0, amount: 0 }] } + case "UPDATE_TAX": { + const taxes = [...state.additionalTaxes] + taxes[action.index] = { ...taxes[action.index], [action.field]: action.value } + if (action.field === "rate") { + const subtotal = state.items.reduce((sum, item) => sum + item.subtotal, 0) + taxes[action.index].amount = (subtotal * Number(action.value)) / 100 + } + return { ...state, additionalTaxes: taxes } + } + case "REMOVE_TAX": + return { ...state, additionalTaxes: state.additionalTaxes.filter((_, i) => i !== action.index) } + case "ADD_FEE": + return { ...state, additionalFees: [...state.additionalFees, { name: "", amount: 0 }] } + case "UPDATE_FEE": { + const fees = [...state.additionalFees] + fees[action.index] = { ...fees[action.index], [action.field]: action.value } + return { ...state, additionalFees: fees } + } + case "REMOVE_FEE": + return { ...state, additionalFees: state.additionalFees.filter((_, i) => i !== action.index) } + default: + return state + } +} + +export function InvoiceGenerator({ + user, + settings, + currencies, + appData, +}: { + user: User + settings: SettingsMap + currencies: Currency[] + appData: InvoiceAppData | null +}) { + const templates: InvoiceTemplate[] = useMemo( + () => [...defaultTemplates(user, settings), ...(appData?.templates || [])], + [appData] + ) + + const [selectedTemplate, setSelectedTemplate] = useState(templates[0].name) + const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false) + const [newTemplateName, setNewTemplateName] = useState("") + const [formData, dispatch] = useReducer(invoiceFormReducer, templates[0].formData) + + const router = useRouter() + + // Function to handle template selection + const handleTemplateSelect = (templateName: string) => { + const template = templates.find((t) => t.name === templateName) + if (template) { + setSelectedTemplate(templateName) + dispatch({ type: "SET_FORM", payload: template.formData }) + } + } + + const handleGeneratePDF = async (e: React.FormEvent) => { + e.preventDefault() + + if (formData.businessLogo) { + formData.businessLogo = await fetchAsBase64(formData.businessLogo) + } + + try { + const pdfBuffer = await generateInvoicePDF(formData) + + // Create a blob from the buffer + const blob = new Blob([pdfBuffer], { type: "application/pdf" }) + + // Create a URL for the blob + const url = URL.createObjectURL(blob) + + // Create a temporary link element + const link = document.createElement("a") + link.href = url + link.download = `invoice-${formData.invoiceNumber}.pdf` + + // Append the link to the document, click it, and remove it + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + // Clean up the URL + URL.revokeObjectURL(url) + } catch (error) { + console.error("Error generating PDF:", error) + alert("Failed to generate PDF. Please try again.") + } + } + + const handleSaveTemplate = async () => { + if (!newTemplateName.trim()) { + alert("Please enter a template name") + return + } + + if (templates.some((t) => t.name === newTemplateName)) { + alert("A template with this name already exists") + return + } + + try { + // Get existing templates + const result = await addNewTemplateAction(user, { + id: `tmpl_${Math.random().toString(36).substring(2, 15)}`, + name: newTemplateName, + formData: formData, + }) + + if (result.success) { + setIsTemplateDialogOpen(false) + setNewTemplateName("") + router.refresh() + } else { + alert("Failed to save template. Please try again.") + } + } catch (error) { + console.error("Error saving template:", error) + alert("Failed to save template. Please try again.") + } + } + + const handleDeleteTemplate = async (templateId: string | undefined, e: React.MouseEvent) => { + e.stopPropagation() + if (!templateId) return // Don't allow deleting default templates + + try { + const result = await deleteTemplateAction(user, templateId) + if (result.success) { + router.refresh() + } + } catch (error) { + console.error("Error deleting template:", error) + alert("Failed to delete template. Please try again.") + } + } + + // Accept optional event, prevent default only if present + const handleSaveAsTransaction = async (e?: React.FormEvent) => { + if (e) e.preventDefault() + + if (formData.businessLogo) { + formData.businessLogo = await fetchAsBase64(formData.businessLogo) + } + + try { + const result = await saveInvoiceAsTransactionAction(formData) + if (result.success && result.data?.id) { + console.log("SUCCESS! REDIRECTING TO TRANSACTION", result.data?.id) + startTransition(() => { + router.push(`/transactions/${result.data?.id}`) + }) + } else { + alert(result.error || "Failed to save as transaction") + } + } catch (error) { + console.error("Error saving as transaction:", error) + alert("Failed to save as transaction. Please try again.") + } + } + + return ( +
+ {/* Templates Section */} +
+ {templates.map((template) => ( +
+ + {template.id && ( + + )} +
+ ))} +
+ +
+ + + {/* Generate PDF Button */} +
+ + + +
+
+ + {/* New Template Dialog */} + + + + Save as Template + +
+ setNewTemplateName(e.target.value)} + placeholder="Enter template name" + className="w-full px-3 py-2 border rounded-md" + /> +
+ + + + +
+
+
+ ) +} diff --git a/app/(app)/apps/invoices/components/invoice-page.tsx b/app/(app)/apps/invoices/components/invoice-page.tsx new file mode 100644 index 0000000..c204b75 --- /dev/null +++ b/app/(app)/apps/invoices/components/invoice-page.tsx @@ -0,0 +1,584 @@ +import { FormSelectCurrency } from "@/components/forms/select-currency" +import { FormAvatar, FormInput, FormTextarea } from "@/components/forms/simple" +import { Button } from "@/components/ui/button" +import { formatCurrency } from "@/lib/utils" +import { Currency } from "@/prisma/client" +import { X } from "lucide-react" +import { InputHTMLAttributes, memo, useCallback, useMemo } from "react" + +export interface InvoiceItem { + description: string + quantity: number + unitPrice: number + subtotal: number +} + +export interface AdditionalTax { + name: string + rate: number + amount: number +} + +export interface AdditionalFee { + name: string + amount: number +} + +export interface InvoiceFormData { + title: string + businessLogo: string | null + invoiceNumber: string + date: string + dueDate: string + currency: string + companyDetails: string + companyDetailsLabel: string + billTo: string + billToLabel: string + items: InvoiceItem[] + taxIncluded: boolean + additionalTaxes: AdditionalTax[] + additionalFees: AdditionalFee[] + notes: string + bankDetails: string + issueDateLabel: string + dueDateLabel: string + itemLabel: string + quantityLabel: string + unitPriceLabel: string + subtotalLabel: string + summarySubtotalLabel: string + summaryTotalLabel: string +} + +interface InvoicePageProps { + invoiceData: InvoiceFormData + dispatch: React.Dispatch + currencies: Currency[] +} + +// Memoized row for invoice items +const ItemRow = memo(function ItemRow({ + item, + index, + onChange, + onRemove, + currency, +}: { + item: InvoiceItem + index: number + onChange: (index: number, field: keyof InvoiceItem, value: string | number) => void + onRemove: (index: number) => void + currency: string +}) { + return ( + + + onChange(index, "description", e.target.value)} + className="w-full min-w-64" + placeholder="Item description" + required + /> + + + onChange(index, "quantity", Number(e.target.value))} + className="w-20 text-right" + required + /> + + + onChange(index, "unitPrice", Number(e.target.value))} + className="w-24 text-right" + required + /> + + {formatCurrency(item.subtotal * 100, currency)} + + + + + ) +}) + +// Memoized row for additional taxes +const TaxRow = memo(function TaxRow({ + tax, + index, + onChange, + onRemove, + currency, +}: { + tax: AdditionalTax + index: number + onChange: (index: number, field: keyof AdditionalTax, value: string | number) => void + onRemove: (index: number) => void + currency: string +}) { + return ( +
+
+ + onChange(index, "name", e.target.value)} + placeholder="Tax name" + /> + onChange(index, "rate", Number(e.target.value))} + className="w-12 text-right" + /> + % + {formatCurrency(tax.amount * 100, currency)} +
+
+ ) +}) + +// Memoized row for additional fees +const FeeRow = memo(function FeeRow({ + fee, + index, + onChange, + onRemove, + currency, +}: { + fee: AdditionalFee + index: number + onChange: (index: number, field: keyof AdditionalFee, value: string | number) => void + onRemove: (index: number) => void + currency: string +}) { + return ( +
+
+ + onChange(index, "name", e.target.value)} + placeholder="Fee or discount name" + /> + onChange(index, "amount", Number(e.target.value))} + className="w-16 text-right" + /> + {formatCurrency(fee.amount * 100, currency)} +
+
+ ) +}) + +export function InvoicePage({ invoiceData, dispatch, currencies }: InvoicePageProps) { + const addItem = useCallback(() => dispatch({ type: "ADD_ITEM" }), [dispatch]) + const removeItem = useCallback((index: number) => dispatch({ type: "REMOVE_ITEM", index }), [dispatch]) + const updateItem = useCallback( + (index: number, field: keyof InvoiceItem, value: string | number) => + dispatch({ type: "UPDATE_ITEM", index, field, value }), + [dispatch] + ) + + const addAdditionalTax = useCallback(() => dispatch({ type: "ADD_TAX" }), [dispatch]) + const removeAdditionalTax = useCallback((index: number) => dispatch({ type: "REMOVE_TAX", index }), [dispatch]) + const updateAdditionalTax = useCallback( + (index: number, field: keyof AdditionalTax, value: string | number) => + dispatch({ type: "UPDATE_TAX", index, field, value }), + [dispatch] + ) + + const addAdditionalFee = useCallback(() => dispatch({ type: "ADD_FEE" }), [dispatch]) + const removeAdditionalFee = useCallback((index: number) => dispatch({ type: "REMOVE_FEE", index }), [dispatch]) + const updateAdditionalFee = useCallback( + (index: number, field: keyof AdditionalFee, value: string | number) => + dispatch({ type: "UPDATE_FEE", index, field, value }), + [dispatch] + ) + + const subtotal = useMemo(() => invoiceData.items.reduce((sum, item) => sum + item.subtotal, 0), [invoiceData.items]) + const taxes = useMemo( + () => invoiceData.additionalTaxes.reduce((sum, tax) => sum + tax.amount, 0), + [invoiceData.additionalTaxes] + ) + const fees = useMemo( + () => invoiceData.additionalFees.reduce((sum, fee) => sum + fee.amount, 0), + [invoiceData.additionalFees] + ) + const total = useMemo( + () => (invoiceData.taxIncluded ? subtotal : subtotal + taxes) + fees, + [invoiceData.taxIncluded, subtotal, taxes, fees] + ) + + return ( +
+ {/* Gradient Background */} +
+ + {/* Invoice Header */} +
+
+ dispatch({ type: "UPDATE_FIELD", field: "title", value: e.target.value })} + className="text-2xl sm:text-4xl font-extrabold" + placeholder="INVOICE" + required + /> + dispatch({ type: "UPDATE_FIELD", field: "invoiceNumber", value: e.target.value })} + className="w-full sm:w-[200px] font-medium" + /> +
+ +
+ { + const file = e.target.files?.[0] + if (file) { + const objectUrl = URL.createObjectURL(file) + dispatch({ type: "UPDATE_FIELD", field: "businessLogo", value: objectUrl }) + } else { + dispatch({ type: "UPDATE_FIELD", field: "businessLogo", value: null }) + } + }} + /> +
+
+ + {/* Company and Bill To */} +
+
+ dispatch({ type: "UPDATE_FIELD", field: "companyDetailsLabel", value: e.target.value })} + className="text-xs sm:text-sm font-medium" + /> + dispatch({ type: "UPDATE_FIELD", field: "companyDetails", value: e.target.value })} + rows={4} + placeholder="Your Company Name, Address, City, State, ZIP, Country, Tax ID" + required + /> +
+
+ dispatch({ type: "UPDATE_FIELD", field: "billToLabel", value: e.target.value })} + className="text-xs sm:text-sm font-medium" + /> + dispatch({ type: "UPDATE_FIELD", field: "billTo", value: e.target.value })} + rows={4} + placeholder="Client Name, Address, City, State, ZIP, Country, Tax ID" + required + /> +
+
+ +
+
+
+ dispatch({ type: "UPDATE_FIELD", field: "issueDateLabel", value: e.target.value })} + className="text-xs sm:text-sm font-medium" + /> + dispatch({ type: "UPDATE_FIELD", field: "date", value: e.target.value })} + className="w-full border-b border-gray-300 py-1" + required + /> +
+ +
+ dispatch({ type: "UPDATE_FIELD", field: "dueDateLabel", value: e.target.value })} + className="text-xs sm:text-sm font-medium" + /> + dispatch({ type: "UPDATE_FIELD", field: "dueDate", value: e.target.value })} + required + /> +
+
+ +
+ dispatch({ type: "UPDATE_FIELD", field: "currency", value })} + /> +
+
+ + {/* Items Table */} +
+
+ {/* Table for desktop/tablet */} +
+ + + + + + + + + + + + {invoiceData.items.map((item, index) => ( + + ))} + +
+ dispatch({ type: "UPDATE_FIELD", field: "itemLabel", value: e.target.value })} + className="text-xs font-medium text-gray-500 uppercase tracking-wider" + /> + + + dispatch({ type: "UPDATE_FIELD", field: "quantityLabel", value: e.target.value }) + } + className="text-xs font-medium text-gray-500 uppercase tracking-wider" + /> + + + dispatch({ type: "UPDATE_FIELD", field: "unitPriceLabel", value: e.target.value }) + } + className="text-xs font-medium text-gray-500 uppercase tracking-wider" + /> + + + dispatch({ type: "UPDATE_FIELD", field: "subtotalLabel", value: e.target.value }) + } + className="text-xs font-medium text-gray-500 uppercase tracking-wider" + /> +
+
+ {/* Flex list for mobile */} +
+ {invoiceData.items.map((item, index) => ( +
+ +
+ + updateItem(index, "description", e.target.value)} + className="w-full min-w-0" + placeholder="Item description" + required + /> +
+
+
+ + updateItem(index, "quantity", Number(e.target.value))} + className="w-full text-right" + required + /> +
+
+ + updateItem(index, "unitPrice", Number(e.target.value))} + className="w-full text-right" + required + /> +
+
+ + + {formatCurrency(item.subtotal * 100, invoiceData.currency)} + +
+
+
+ ))} +
+ +
+
+ + {/* Notes */} +
+ dispatch({ type: "UPDATE_FIELD", field: "notes", value: e.target.value })} + className="w-full border border-gray-300 rounded p-2 text-xs sm:text-sm" + rows={3} + placeholder="Additional notes or terms" + /> +
+ + {/* Summary */} +
+
+
+ dispatch({ type: "UPDATE_FIELD", field: "summarySubtotalLabel", value: e.target.value })} + className="text-xs sm:text-sm font-medium text-gray-600" + /> + {formatCurrency(subtotal * 100, invoiceData.currency)} +
+ +
+ {/* Additional Taxes */} + {invoiceData.additionalTaxes.map((tax, index) => ( + + ))} + +
+ +
+ + {invoiceData.additionalFees.map((fee, index) => ( + + ))} + +
+ +
+
+ + +
+ dispatch({ type: "UPDATE_FIELD", field: "summaryTotalLabel", value: e.target.value })} + className="text-sm sm:text-md font-bold" + /> + + {formatCurrency(total * 100, invoiceData.currency)} + +
+
+
+ + {/* Bank Details Footer */} +
+