mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user