mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
"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, Loader2, 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,
|
|
{ name: "", subtitle: "", showSubtitle: false, 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 [isPdfLoading, setIsPdfLoading] = useState(false)
|
|
const [isSavingTransaction, setIsSavingTransaction] = useState(false)
|
|
|
|
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()
|
|
setIsPdfLoading(true)
|
|
|
|
try {
|
|
if (formData.businessLogo) {
|
|
formData.businessLogo = await fetchAsBase64(formData.businessLogo)
|
|
}
|
|
|
|
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.")
|
|
} finally {
|
|
setIsPdfLoading(false)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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()
|
|
setIsSavingTransaction(true)
|
|
|
|
try {
|
|
if (formData.businessLogo) {
|
|
formData.businessLogo = await fetchAsBase64(formData.businessLogo)
|
|
}
|
|
|
|
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.")
|
|
} finally {
|
|
setIsSavingTransaction(false)
|
|
}
|
|
}
|
|
|
|
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} disabled={isPdfLoading}>
|
|
{isPdfLoading ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Generating...
|
|
</>
|
|
) : (
|
|
<>
|
|
<FileDown className="mr-2" />
|
|
Download PDF
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button variant="secondary" onClick={() => setIsTemplateDialogOpen(true)}>
|
|
<TextSelect />
|
|
Make a Template
|
|
</Button>
|
|
<Button variant="secondary" onClick={handleSaveAsTransaction} disabled={isSavingTransaction}>
|
|
{isSavingTransaction ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="mr-2" />
|
|
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>
|
|
)
|
|
}
|