Files
TaxHacker_s23/app/(app)/apps/invoices/components/invoice-page.tsx
2025-05-07 14:53:13 +02:00

585 lines
22 KiB
TypeScript

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}
/>
)
}