mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 21:35:19 +00:00
feat: invoice generator
This commit is contained in:
278
app/(app)/apps/invoices/components/invoice-generator.tsx
Normal file
278
app/(app)/apps/invoices/components/invoice-generator.tsx
Normal file
@@ -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<string>(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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Templates Section */}
|
||||
<div className="py-4 flex overflow-x-auto gap-2">
|
||||
{templates.map((template) => (
|
||||
<div key={template.name} className="relative group">
|
||||
<Button
|
||||
variant={selectedTemplate === template.name ? "default" : "outline"}
|
||||
className={`
|
||||
whitespace-nowrap p-4
|
||||
${selectedTemplate === template.name ? "bg-black hover:bg-gray-900" : "border-gray-300 text-gray-700 hover:bg-gray-100"}
|
||||
`}
|
||||
onClick={() => handleTemplateSelect(template.name)}
|
||||
>
|
||||
{template.name}
|
||||
</Button>
|
||||
{template.id && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute -top-2 -right-2 h-5 w-5 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => handleDeleteTemplate(template.id, e)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row flex-wrap justify-start items-start gap-4">
|
||||
<InvoicePage invoiceData={formData} dispatch={dispatch} currencies={currencies} />
|
||||
|
||||
{/* Generate PDF Button */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button onClick={handleGeneratePDF}>
|
||||
<FileDown />
|
||||
Download PDF
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setIsTemplateDialogOpen(true)}>
|
||||
<TextSelect />
|
||||
Make a Template
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleSaveAsTransaction}>
|
||||
<Save />
|
||||
Save as Transaction
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Template Dialog */}
|
||||
<Dialog open={isTemplateDialogOpen} onOpenChange={setIsTemplateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save as Template</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newTemplateName}
|
||||
onChange={(e) => setNewTemplateName(e.target.value)}
|
||||
placeholder="Enter template name"
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsTemplateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveTemplate}>Save Template</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
584
app/(app)/apps/invoices/components/invoice-page.tsx
Normal file
584
app/(app)/apps/invoices/components/invoice-page.tsx
Normal file
@@ -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<any>
|
||||
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 (
|
||||
<tr>
|
||||
<td className="px-4 py-2">
|
||||
<FormInput
|
||||
type="text"
|
||||
value={item.description}
|
||||
onChange={(e) => onChange(index, "description", e.target.value)}
|
||||
className="w-full min-w-64"
|
||||
placeholder="Item description"
|
||||
required
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<FormInput
|
||||
type="number"
|
||||
min="1"
|
||||
value={item.quantity}
|
||||
onChange={(e) => onChange(index, "quantity", Number(e.target.value))}
|
||||
className="w-20 text-right"
|
||||
required
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<FormInput
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={item.unitPrice}
|
||||
onChange={(e) => onChange(index, "unitPrice", Number(e.target.value))}
|
||||
className="w-24 text-right"
|
||||
required
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right">{formatCurrency(item.subtotal * 100, currency)}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Button variant="destructive" className="rounded-full p-1 h-5 w-5" onClick={() => onRemove(index)}>
|
||||
<X />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
|
||||
// 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 (
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="w-full flex flex-row gap-2 items-center">
|
||||
<Button variant="destructive" className="rounded-full p-1 h-5 w-5" onClick={() => onRemove(index)}>
|
||||
<X />
|
||||
</Button>
|
||||
<FormInput
|
||||
type="text"
|
||||
value={tax.name}
|
||||
onChange={(e) => onChange(index, "name", e.target.value)}
|
||||
placeholder="Tax name"
|
||||
/>
|
||||
<FormInput
|
||||
type="number"
|
||||
max="100"
|
||||
value={tax.rate}
|
||||
onChange={(e) => onChange(index, "rate", Number(e.target.value))}
|
||||
className="w-12 text-right"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">%</span>
|
||||
<span className="text-sm text-nowrap">{formatCurrency(tax.amount * 100, currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// 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 (
|
||||
<div className="w-full flex justify-between items-center">
|
||||
<div className="w-full flex flex-row gap-2 items-center justify-between">
|
||||
<Button variant="destructive" className="rounded-full p-1 h-5 w-5" onClick={() => onRemove(index)}>
|
||||
<X />
|
||||
</Button>
|
||||
<FormInput
|
||||
type="text"
|
||||
value={fee.name}
|
||||
onChange={(e) => onChange(index, "name", e.target.value)}
|
||||
placeholder="Fee or discount name"
|
||||
/>
|
||||
<FormInput
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={fee.amount}
|
||||
onChange={(e) => onChange(index, "amount", Number(e.target.value))}
|
||||
className="w-16 text-right"
|
||||
/>
|
||||
<span className="text-sm text-nowrap">{formatCurrency(fee.amount * 100, currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
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 (
|
||||
<div className="relative w-full max-w-[794px] sm:w-[794px] min-h-[297mm] bg-white shadow-lg p-2 sm:p-8 mb-8">
|
||||
{/* Gradient Background */}
|
||||
<div className="absolute top-0 left-0 right-0 h-[25%] bg-gradient-to-b from-indigo-100 to-indigo-0 opacity-70" />
|
||||
|
||||
{/* Invoice Header */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-8 justify-between items-start mb-8 relative">
|
||||
<div className="w-full flex flex-col space-y-2">
|
||||
<ShadyFormInput
|
||||
type="text"
|
||||
value={invoiceData.title}
|
||||
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "title", value: e.target.value })}
|
||||
className="text-2xl sm:text-4xl font-extrabold"
|
||||
placeholder="INVOICE"
|
||||
required
|
||||
/>
|
||||
<FormInput
|
||||
placeholder="Invoice ID or subtitle"
|
||||
value={invoiceData.invoiceNumber}
|
||||
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "invoiceNumber", value: e.target.value })}
|
||||
className="w-full sm:w-[200px] font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-end mt-4 sm:mt-0">
|
||||
<FormAvatar
|
||||
name="businessLogo"
|
||||
className="w-[60px] h-[60px] sm:w-[100px] sm:h-[100px]"
|
||||
defaultValue={invoiceData.businessLogo || ""}
|
||||
onChange={(e) => {
|
||||
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 })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company and Bill To */}
|
||||
<div className="relative grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-8 mb-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<ShadyFormInput
|
||||
type="text"
|
||||
value={invoiceData.companyDetailsLabel}
|
||||
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "companyDetailsLabel", value: e.target.value })}
|
||||
className="text-xs sm:text-sm font-medium"
|
||||
/>
|
||||
<FormTextarea
|
||||
value={invoiceData.companyDetails}
|
||||
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "companyDetails", value: e.target.value })}
|
||||
rows={4}
|
||||
placeholder="Your Company Name, Address, City, State, ZIP, Country, Tax ID"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<ShadyFormInput
|
||||
type="text"
|
||||
value={invoiceData.billToLabel}
|
||||
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "billToLabel", value: e.target.value })}
|
||||
className="text-xs sm:text-sm font-medium"
|
||||
/>
|
||||
<FormTextarea
|
||||
value={invoiceData.billTo}
|
||||
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "billTo", value: e.target.value })}
|
||||
rows={4}
|
||||
placeholder="Client Name, Address, City, State, ZIP, Country, Tax ID"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col sm:flex-row items-start sm:items-end justify-between mb-8 gap-4">
|
||||
<div className="flex flex-row items-center gap-4 w-full sm:w-auto">
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<ShadyFormInput
|
||||
type="text"
|
||||
value={invoiceData.issueDateLabel}
|
||||
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "issueDateLabel", value: e.target.value })}
|
||||
className="text-xs sm:text-sm font-medium"
|
||||
/>
|
||||
<FormInput
|
||||
type="date"
|
||||
value={invoiceData.date}
|
||||
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "date", value: e.target.value })}
|
||||
className="w-full border-b border-gray-300 py-1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<ShadyFormInput
|
||||
type="text"
|
||||
value={invoiceData.dueDateLabel}
|
||||
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "dueDateLabel", value: e.target.value })}
|
||||
className="text-xs sm:text-sm font-medium"
|
||||
/>
|
||||
<FormInput
|
||||
type="date"
|
||||
value={invoiceData.dueDate}
|
||||
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "dueDate", value: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full sm:w-auto flex justify-end">
|
||||
<FormSelectCurrency
|
||||
currencies={currencies}
|
||||
value={invoiceData.currency}
|
||||
onValueChange={(value) => dispatch({ type: "UPDATE_FIELD", field: "currency", value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items Table */}
|
||||
<div className="mb-8">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{/* Table for desktop/tablet */}
|
||||
<div className="overflow-x-auto sm:block hidden">
|
||||
<table className="min-w-[600px] w-full divide-y divide-gray-200 text-xs sm:text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-2 sm:px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<ShadyFormInput
|
||||
type="text"
|
||||
value={invoiceData.itemLabel}
|
||||
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "itemLabel", value: e.target.value })}
|
||||
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-2 sm:px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<ShadyFormInput
|
||||
type="text"
|
||||
value={invoiceData.quantityLabel}
|
||||
onChange={(e) =>
|
||||
dispatch({ type: "UPDATE_FIELD", field: "quantityLabel", value: e.target.value })
|
||||
}
|
||||
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-2 sm:px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<ShadyFormInput
|
||||
type="text"
|
||||
value={invoiceData.unitPriceLabel}
|
||||
onChange={(e) =>
|
||||
dispatch({ type: "UPDATE_FIELD", field: "unitPriceLabel", value: e.target.value })
|
||||
}
|
||||
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-2 sm:px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<ShadyFormInput
|
||||
type="text"
|
||||
value={invoiceData.subtotalLabel}
|
||||
onChange={(e) =>
|
||||
dispatch({ type: "UPDATE_FIELD", field: "subtotalLabel", value: e.target.value })
|
||||
}
|
||||
className="text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
/>
|
||||
</th>
|
||||
<th className="px-2 sm:px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{invoiceData.items.map((item, index) => (
|
||||
<ItemRow
|
||||
key={index}
|
||||
item={item}
|
||||
index={index}
|
||||
onChange={updateItem}
|
||||
onRemove={removeItem}
|
||||
currency={invoiceData.currency}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Flex list for mobile */}
|
||||
<div className="sm:hidden flex flex-col gap-2 p-2">
|
||||
{invoiceData.items.map((item, index) => (
|
||||
<div key={index} className="flex flex-col gap-1 border rounded-lg p-3 bg-gray-50 relative">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-2 right-2 text-gray-400 hover:text-red-500"
|
||||
onClick={() => removeItem(index)}
|
||||
aria-label="Remove item"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500 font-medium">{invoiceData.itemLabel}</label>
|
||||
<FormInput
|
||||
type="text"
|
||||
value={item.description}
|
||||
onChange={(e) => updateItem(index, "description", e.target.value)}
|
||||
className="w-full min-w-0"
|
||||
placeholder="Item description"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<div className="flex-1 flex flex-col">
|
||||
<label className="text-xs text-gray-500 font-medium">{invoiceData.quantityLabel}</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
min="1"
|
||||
value={item.quantity}
|
||||
onChange={(e) => updateItem(index, "quantity", Number(e.target.value))}
|
||||
className="w-full text-right"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col">
|
||||
<label className="text-xs text-gray-500 font-medium">{invoiceData.unitPriceLabel}</label>
|
||||
<FormInput
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={item.unitPrice}
|
||||
onChange={(e) => updateItem(index, "unitPrice", Number(e.target.value))}
|
||||
className="w-full text-right"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-end">
|
||||
<label className="text-xs text-gray-500 font-medium">{invoiceData.subtotalLabel}</label>
|
||||
<span className="text-sm font-semibold mt-2">
|
||||
{formatCurrency(item.subtotal * 100, invoiceData.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button onClick={addItem} className="m-2 sm:m-3 w-full sm:w-auto">
|
||||
+ Add Item
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="mb-8">
|
||||
<FormTextarea
|
||||
value={invoiceData.notes}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="flex justify-end">
|
||||
<div className="w-full sm:w-72 space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<ShadyFormInput
|
||||
type="text"
|
||||
value={invoiceData.summarySubtotalLabel}
|
||||
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "summarySubtotalLabel", value: e.target.value })}
|
||||
className="text-xs sm:text-sm font-medium text-gray-600"
|
||||
/>
|
||||
<span className="text-xs sm:text-sm">{formatCurrency(subtotal * 100, invoiceData.currency)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
{/* Additional Taxes */}
|
||||
{invoiceData.additionalTaxes.map((tax, index) => (
|
||||
<TaxRow
|
||||
key={index}
|
||||
tax={tax}
|
||||
index={index}
|
||||
onChange={updateAdditionalTax}
|
||||
onRemove={removeAdditionalTax}
|
||||
currency={invoiceData.currency}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="w-full flex justify-end">
|
||||
<Button onClick={addAdditionalTax} className="w-full sm:w-auto">
|
||||
+ Add Tax
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{invoiceData.additionalFees.map((fee, index) => (
|
||||
<FeeRow
|
||||
key={index}
|
||||
fee={fee}
|
||||
index={index}
|
||||
onChange={updateAdditionalFee}
|
||||
onRemove={removeAdditionalFee}
|
||||
currency={invoiceData.currency}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="w-full flex justify-end">
|
||||
<Button onClick={addAdditionalFee} className="w-full sm:w-auto">
|
||||
+ Add Fee or Discount
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center space-x-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={invoiceData.taxIncluded}
|
||||
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "taxIncluded", value: e.target.checked })}
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="text-gray-600 text-xs sm:text-sm">Taxes are included in price</span>
|
||||
</label>
|
||||
<div className="flex justify-between border-t pt-2">
|
||||
<ShadyFormInput
|
||||
type="text"
|
||||
value={invoiceData.summaryTotalLabel}
|
||||
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "summaryTotalLabel", value: e.target.value })}
|
||||
className="text-sm sm:text-md font-bold"
|
||||
/>
|
||||
<span className="text-sm sm:text-md font-bold text-nowrap">
|
||||
{formatCurrency(total * 100, invoiceData.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bank Details Footer */}
|
||||
<div className="mt-8 pt-8 border-t">
|
||||
<textarea
|
||||
value={invoiceData.bankDetails}
|
||||
onChange={(e) => dispatch({ type: "UPDATE_FIELD", field: "bankDetails", value: e.target.value })}
|
||||
className="text-center text-xs sm:text-sm text-muted-foreground w-full mx-auto border border-gray-300 rounded p-2"
|
||||
rows={3}
|
||||
placeholder="Bank and Payment Details: Account number, Bank name, IBAN, SWIFT/BIC, Your Email (optional)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShadyFormInput({ className = "", ...props }: { className?: string } & InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
className={`bg-transparent border border-transparent outline-none p-0 w-full hover:border-dashed hover:border-gray-200 hover:bg-gray-50 focus:bg-gray-50 hover:rounded-sm ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
403
app/(app)/apps/invoices/components/invoice-pdf.tsx
Normal file
403
app/(app)/apps/invoices/components/invoice-pdf.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { Document, Font, Image, Page, StyleSheet, Text, View } from "@react-pdf/renderer"
|
||||
import { formatDate } from "date-fns"
|
||||
import { ReactElement } from "react"
|
||||
import { AdditionalFee, AdditionalTax, InvoiceFormData, InvoiceItem } from "./invoice-page"
|
||||
|
||||
Font.register({
|
||||
family: "Inter",
|
||||
fonts: [
|
||||
{
|
||||
src: "public/fonts/Inter/Inter-Regular.otf",
|
||||
fontWeight: 400,
|
||||
fontStyle: "normal",
|
||||
},
|
||||
{
|
||||
src: "public/fonts/Inter/Inter-Medium.otf",
|
||||
fontWeight: 500,
|
||||
fontStyle: "normal",
|
||||
},
|
||||
{
|
||||
src: "public/fonts/Inter/Inter-SemiBold.otf",
|
||||
fontWeight: 600,
|
||||
fontStyle: "normal",
|
||||
},
|
||||
{
|
||||
src: "public/fonts/Inter/Inter-Bold.otf",
|
||||
fontWeight: 700,
|
||||
fontStyle: "normal",
|
||||
},
|
||||
{
|
||||
src: "public/fonts/Inter/Inter-ExtraBold.otf",
|
||||
fontWeight: 800,
|
||||
fontStyle: "normal",
|
||||
},
|
||||
{
|
||||
src: "public/fonts/Inter/Inter-Black.otf",
|
||||
fontWeight: 900,
|
||||
fontStyle: "normal",
|
||||
},
|
||||
{
|
||||
src: "public/fonts/Inter-Italic.otf",
|
||||
fontWeight: 400,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
{
|
||||
src: "public/fonts/Inter/Inter-MediumItalic.otf",
|
||||
fontWeight: 500,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
{
|
||||
src: "public/fonts/Inter/Inter-SemiBoldItalic.otf",
|
||||
fontWeight: 600,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
{
|
||||
src: "public/fonts/Inter/Inter-BoldItalic.otf",
|
||||
fontWeight: 700,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
{
|
||||
src: "public/fonts/Inter/Inter-ExtraBoldItalic.otf",
|
||||
fontWeight: 800,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
{
|
||||
src: "public/fonts/Inter/Inter-BlackItalic.otf",
|
||||
fontWeight: 900,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
Font.registerEmojiSource({
|
||||
format: "png",
|
||||
url: "https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/",
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
fontFamily: "Inter",
|
||||
padding: 30,
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
header: {
|
||||
marginBottom: 30,
|
||||
position: "relative",
|
||||
},
|
||||
gradientBackground: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "120px",
|
||||
backgroundColor: "#eef2ff",
|
||||
opacity: 0.8,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 30,
|
||||
},
|
||||
headerLeft: {
|
||||
flex: 1,
|
||||
},
|
||||
headerRight: {
|
||||
width: 110,
|
||||
height: 60,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
marginBottom: 10,
|
||||
fontWeight: "extrabold",
|
||||
color: "#000000",
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
color: "#666666",
|
||||
},
|
||||
logo: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
},
|
||||
companyDetails: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 30,
|
||||
},
|
||||
companySection: {
|
||||
flex: 1,
|
||||
marginRight: 30,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#000000",
|
||||
},
|
||||
sectionContent: {
|
||||
fontSize: 12,
|
||||
lineHeight: 1.3,
|
||||
color: "#000000",
|
||||
},
|
||||
datesAndCurrency: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-end",
|
||||
marginBottom: 30,
|
||||
},
|
||||
dateGroup: {
|
||||
flexDirection: "row",
|
||||
gap: 20,
|
||||
},
|
||||
dateItem: {
|
||||
marginRight: 20,
|
||||
},
|
||||
dateLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#000000",
|
||||
},
|
||||
dateValue: {
|
||||
fontSize: 12,
|
||||
color: "#000000",
|
||||
},
|
||||
itemsTable: {
|
||||
marginBottom: 30,
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: "row",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#E5E7EB",
|
||||
paddingVertical: 8,
|
||||
backgroundColor: "#F9FAFB",
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: "row",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#E5E7EB",
|
||||
paddingVertical: 8,
|
||||
},
|
||||
colDescription: {
|
||||
flex: 2,
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
colQuantity: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 10,
|
||||
textAlign: "right",
|
||||
},
|
||||
colPrice: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 10,
|
||||
textAlign: "right",
|
||||
},
|
||||
colSubtotal: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 10,
|
||||
textAlign: "right",
|
||||
},
|
||||
colHeader: {
|
||||
fontSize: 10,
|
||||
fontWeight: "bold",
|
||||
color: "#6B7280",
|
||||
},
|
||||
colValue: {
|
||||
fontSize: 12,
|
||||
color: "#000000",
|
||||
},
|
||||
notes: {
|
||||
marginBottom: 30,
|
||||
fontSize: 12,
|
||||
},
|
||||
notesLabel: {
|
||||
fontWeight: "bold",
|
||||
marginBottom: 5,
|
||||
color: "#000000",
|
||||
},
|
||||
summary: {
|
||||
width: "50%",
|
||||
alignSelf: "flex-end",
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 5,
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: 12,
|
||||
color: "#4B5563",
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: 12,
|
||||
color: "#000000",
|
||||
},
|
||||
taxRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 5,
|
||||
},
|
||||
totalRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 10,
|
||||
paddingTop: 10,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#000000",
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: "bold",
|
||||
color: "#000000",
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: "bold",
|
||||
color: "#000000",
|
||||
},
|
||||
bankDetails: {
|
||||
marginTop: 30,
|
||||
paddingTop: 20,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: "#E5E7EB",
|
||||
textAlign: "center",
|
||||
fontSize: 11,
|
||||
color: "#6B7280",
|
||||
},
|
||||
})
|
||||
|
||||
export function InvoicePDF({ data }: { data: InvoiceFormData }): ReactElement {
|
||||
const calculateSubtotal = (): number => {
|
||||
return data.items.reduce((sum: number, item: InvoiceItem) => sum + item.subtotal, 0)
|
||||
}
|
||||
|
||||
const calculateTaxes = (): number => {
|
||||
return data.additionalTaxes.reduce((sum: number, tax: AdditionalTax) => sum + tax.amount, 0)
|
||||
}
|
||||
|
||||
const calculateTotal = (): number => {
|
||||
const subtotal = calculateSubtotal()
|
||||
const taxes = calculateTaxes()
|
||||
return data.taxIncluded ? subtotal : subtotal + taxes
|
||||
}
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.gradientBackground} />
|
||||
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerContent}>
|
||||
<View style={styles.headerLeft}>
|
||||
<Text style={styles.title}>{data.title}</Text>
|
||||
<Text style={styles.subtitle}>{data.invoiceNumber}</Text>
|
||||
</View>
|
||||
{data.businessLogo && (
|
||||
<View style={styles.headerRight}>
|
||||
<Image src={data.businessLogo} style={styles.logo} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Company Details and Bill To */}
|
||||
<View style={styles.companyDetails}>
|
||||
<View style={styles.companySection}>
|
||||
<Text style={styles.sectionLabel}>{data.companyDetailsLabel}</Text>
|
||||
<Text style={styles.sectionContent}>{data.companyDetails}</Text>
|
||||
</View>
|
||||
<View style={styles.companySection}>
|
||||
<Text style={styles.sectionLabel}>{data.billToLabel}</Text>
|
||||
<Text style={styles.sectionContent}>{data.billTo}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Dates and Currency */}
|
||||
<View style={styles.datesAndCurrency}>
|
||||
<View style={styles.dateGroup}>
|
||||
<View style={styles.dateItem}>
|
||||
<Text style={styles.dateLabel}>{data.issueDateLabel}</Text>
|
||||
<Text style={styles.dateValue}>{formatDate(data.date, "yyyy-MM-dd")}</Text>
|
||||
</View>
|
||||
<View style={styles.dateItem}>
|
||||
<Text style={styles.dateLabel}>{data.dueDateLabel}</Text>
|
||||
<Text style={styles.dateValue}>{formatDate(data.dueDate, "yyyy-MM-dd")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Items Table */}
|
||||
<View style={styles.itemsTable}>
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={[styles.colHeader, styles.colDescription]}>{data.itemLabel}</Text>
|
||||
<Text style={[styles.colHeader, styles.colQuantity]}>{data.quantityLabel}</Text>
|
||||
<Text style={[styles.colHeader, styles.colPrice]}>{data.unitPriceLabel}</Text>
|
||||
<Text style={[styles.colHeader, styles.colSubtotal]}>{data.subtotalLabel}</Text>
|
||||
</View>
|
||||
|
||||
{data.items.map((item: InvoiceItem, index: number) => (
|
||||
<View key={index} style={styles.tableRow}>
|
||||
<Text style={[styles.colValue, styles.colDescription]}>{item.description}</Text>
|
||||
<Text style={[styles.colValue, styles.colQuantity]}>{item.quantity}</Text>
|
||||
<Text style={[styles.colValue, styles.colPrice]}>
|
||||
{formatCurrency(item.unitPrice * 100, data.currency)}
|
||||
</Text>
|
||||
<Text style={[styles.colValue, styles.colSubtotal]}>
|
||||
{formatCurrency(item.subtotal * 100, data.currency)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Notes */}
|
||||
{data.notes && (
|
||||
<View style={styles.notes}>
|
||||
<Text style={styles.notesLabel}>Notes:</Text>
|
||||
<Text>{data.notes}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<View style={styles.summary}>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>{data.summarySubtotalLabel}</Text>
|
||||
<Text style={styles.summaryValue}>{formatCurrency(calculateSubtotal() * 100, data.currency)}</Text>
|
||||
</View>
|
||||
|
||||
{data.additionalTaxes.map((tax: AdditionalTax, index: number) => (
|
||||
<View key={index} style={styles.taxRow}>
|
||||
<Text style={styles.summaryLabel}>
|
||||
{tax.name} ({tax.rate}%):
|
||||
</Text>
|
||||
<Text style={styles.summaryValue}>{formatCurrency(tax.amount * 100, data.currency)}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{data.additionalFees.map((fee: AdditionalFee, index: number) => (
|
||||
<View key={index} style={styles.taxRow}>
|
||||
<Text style={styles.summaryLabel}>{fee.name}</Text>
|
||||
<Text style={styles.summaryValue}>{formatCurrency(fee.amount * 100, data.currency)}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>{data.summaryTotalLabel}</Text>
|
||||
<Text style={styles.totalValue}>{formatCurrency(calculateTotal() * 100, data.currency)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bank Details */}
|
||||
{data.bankDetails && (
|
||||
<View style={styles.bankDetails}>
|
||||
<Text>{data.bankDetails}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Page>
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user