251 lines
12 KiB
TypeScript
251 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import { Container } from "@/components/container";
|
|
import { Heading } from "@/components/heading";
|
|
import { Subheading } from "@/components/subheading";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Plus, Trash2, Download, Printer } from "lucide-react";
|
|
|
|
interface Item {
|
|
id: string;
|
|
description: string;
|
|
quantity: number;
|
|
price: number;
|
|
}
|
|
|
|
export function InvoiceGenerator() {
|
|
const [clientName, setClientName] = useState("");
|
|
const [clientEmail, setClientEmail] = useState("");
|
|
const [clientAddress, setClientAddress] = useState("");
|
|
const [invoiceNumber, setInvoiceNumber] = useState("INV-001");
|
|
const [invoiceDate, setInvoiceDate] = useState(new Date().toISOString().split('T')[0]);
|
|
const [items, setItems] = useState<Item[]>([{ id: "1", description: "", quantity: 1, price: 0 }]);
|
|
const [taxRate, setTaxRate] = useState(0);
|
|
const [paymentTerms, setPaymentTerms] = useState("Net 30");
|
|
|
|
const addItem = () => {
|
|
setItems([...items, { id: Math.random().toString(36).substr(2, 9), description: "", quantity: 1, price: 0 }]);
|
|
};
|
|
|
|
const removeItem = (id: string) => {
|
|
if (items.length > 1) {
|
|
setItems(items.filter(item => item.id !== id));
|
|
}
|
|
};
|
|
|
|
const updateItem = (id: string, field: keyof Item, value: string | number) => {
|
|
setItems(items.map(item => item.id === id ? { ...item, [field]: value } : item));
|
|
};
|
|
|
|
const subtotal = items.reduce((acc, item) => acc + (item.quantity * item.price), 0);
|
|
const taxAmount = subtotal * (taxRate / 100);
|
|
const total = subtotal + taxAmount;
|
|
|
|
const handlePrint = () => {
|
|
window.print();
|
|
};
|
|
|
|
return (
|
|
<section className="py-12 bg-neutral-50 min-h-screen">
|
|
<Container>
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
|
|
<div>
|
|
<Heading>Invoice Generator</Heading>
|
|
<Subheading>Create professional invoices for your clients in seconds.</Subheading>
|
|
</div>
|
|
<div className="flex gap-2 no-print">
|
|
<Button onClick={handlePrint} variant="outline" className="flex items-center gap-2">
|
|
<Printer className="w-4 h-4" /> Print / PDF
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
{/* Editor Side */}
|
|
<div className="lg:col-span-2 space-y-6 no-print">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Client Information</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="clientName">Client Name</Label>
|
|
<Input id="clientName" value={clientName} onChange={(e) => setClientName(e.target.value)} placeholder="Acme Corp" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="clientEmail">Client Email</Label>
|
|
<Input id="clientEmail" type="email" value={clientEmail} onChange={(e) => setClientEmail(e.target.value)} placeholder="billing@acme.com" />
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="clientAddress">Client Address</Label>
|
|
<Textarea id="clientAddress" value={clientAddress} onChange={(e) => setClientAddress(e.target.value)} placeholder="123 Business St, City, Country" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle>Line Items</CardTitle>
|
|
<Button onClick={addItem} size="sm" variant="secondary" className="flex items-center gap-1">
|
|
<Plus className="w-4 h-4" /> Add Item
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{items.map((item) => (
|
|
<div key={item.id} className="grid grid-cols-12 gap-2 items-end border-b pb-4 last:border-0">
|
|
<div className="col-span-6 space-y-1">
|
|
<Label className="text-xs">Description</Label>
|
|
<Input value={item.description} onChange={(e) => updateItem(item.id, 'description', e.target.value)} placeholder="Service or product name" />
|
|
</div>
|
|
<div className="col-span-2 space-y-1">
|
|
<Label className="text-xs">Qty</Label>
|
|
<Input type="number" value={item.quantity} onChange={(e) => updateItem(item.id, 'quantity', parseFloat(e.target.value) || 0)} />
|
|
</div>
|
|
<div className="col-span-3 space-y-1">
|
|
<Label className="text-xs">Price</Label>
|
|
<Input type="number" value={item.price} onChange={(e) => updateItem(item.id, 'price', parseFloat(e.target.value) || 0)} />
|
|
</div>
|
|
<div className="col-span-1 pb-1">
|
|
<Button variant="ghost" size="icon" onClick={() => removeItem(item.id)} className="text-red-500 hover:text-red-700 hover:bg-red-50">
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Settings & Terms</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="taxRate">Tax Rate (%)</Label>
|
|
<Input id="taxRate" type="number" value={taxRate} onChange={(e) => setTaxRate(parseFloat(e.target.value) || 0)} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="invoiceNum">Invoice #</Label>
|
|
<Input id="invoiceNum" value={invoiceNumber} onChange={(e) => setInvoiceNumber(e.target.value)} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="paymentTerms">Payment Terms</Label>
|
|
<Input id="paymentTerms" value={paymentTerms} onChange={(e) => setPaymentTerms(e.target.value)} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Preview Side */}
|
|
<div className="lg:col-span-1">
|
|
<div className="sticky top-8">
|
|
<Card className="print:shadow-none print:border-none overflow-hidden">
|
|
<div className="bg-neutral-900 text-white p-6">
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<h2 className="text-2xl font-bold">INVOICE</h2>
|
|
<p className="text-neutral-400 text-sm">{invoiceNumber}</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="font-semibold">InvoiceGen</p>
|
|
<p className="text-xs text-neutral-400">Your Business Name</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<CardContent className="p-6 space-y-6">
|
|
<div className="flex justify-between text-sm">
|
|
<div>
|
|
<p className="text-neutral-500 uppercase tracking-wider font-semibold text-[10px] mb-1">Bill To</p>
|
|
<p className="font-bold">{clientName || "Client Name"}</p>
|
|
<p className="text-neutral-600 whitespace-pre-line">{clientAddress || "Client Address"}</p>
|
|
<p className="text-neutral-600">{clientEmail}</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-neutral-500 uppercase tracking-wider font-semibold text-[10px] mb-1">Date</p>
|
|
<p className="font-medium">{invoiceDate}</p>
|
|
<p className="text-neutral-500 uppercase tracking-wider font-semibold text-[10px] mt-3 mb-1">Terms</p>
|
|
<p className="font-medium">{paymentTerms}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-b py-4">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-neutral-500 text-left">
|
|
<th className="pb-2 font-medium">Description</th>
|
|
<th className="pb-2 font-medium text-right">Qty</th>
|
|
<th className="pb-2 font-medium text-right">Price</th>
|
|
<th className="pb-2 font-medium text-right">Total</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{items.map((item) => (
|
|
<tr key={item.id}>
|
|
<td className="py-2">{item.description || "New Item"}</td>
|
|
<td className="py-2 text-right">{item.quantity}</td>
|
|
<td className="py-2 text-right">${item.price.toFixed(2)}</td>
|
|
<td className="py-2 text-right font-medium">${(item.quantity * item.price).toFixed(2)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-neutral-500">Subtotal</span>
|
|
<span>${subtotal.toFixed(2)}</span>
|
|
</div>
|
|
{taxRate > 0 && (
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-neutral-500">Tax ({taxRate}%)</span>
|
|
<span>${taxAmount.toFixed(2)}</span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between text-lg font-bold pt-2 border-t">
|
|
<span>Total</span>
|
|
<span>${total.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-8 text-[10px] text-neutral-400 text-center">
|
|
<p>Thank you for your business!</p>
|
|
<p className="mt-1">Generated via InvoiceGen</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<style jsx global>{`
|
|
@media print {
|
|
body * {
|
|
visibility: hidden;
|
|
}
|
|
.print\\:shadow-none, .print\\:shadow-none * {
|
|
visibility: visible;
|
|
}
|
|
.print\\:shadow-none {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
width: 100%;
|
|
}
|
|
.no-print {
|
|
display: none !important;
|
|
}
|
|
}
|
|
`}</style>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Container>
|
|
</section>
|
|
);
|
|
}
|