Files
beenvoice-web/src/components/invoice-list.tsx
T
soconnor 2d217fab47 feat: implement complete invoicing application with CSV import and PDF export
- Add comprehensive CSV import system with drag-and-drop upload and validation
- Create UniversalTable component with advanced filtering, searching, and batch actions
- Implement invoice management (view, edit, delete) with professional PDF export
- Add client management with full CRUD operations
- Set up authentication with NextAuth.js and email/password login
- Configure database schema with users, clients, invoices, and invoice_items tables
- Build responsive UI with shadcn/ui components and emerald branding
- Add type-safe API layer with tRPC and Zod validation
- Include proper error handling and user feedback with toast notifications
- Set up development environment with Bun, TypeScript, and Tailwind CSS
2025-07-10 04:07:19 -04:00

207 lines
6.9 KiB
TypeScript

"use client";
import { useState } from "react";
import Link from "next/link";
import { api } from "~/trpc/react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
import { toast } from "sonner";
import { FileText, Calendar, DollarSign, Edit, Trash2, Eye, Plus, User } from "lucide-react";
const statusColors = {
draft: "bg-gray-100 text-gray-800",
sent: "bg-blue-100 text-blue-800",
paid: "bg-green-100 text-green-800",
overdue: "bg-red-100 text-red-800",
};
const statusLabels = {
draft: "Draft",
sent: "Sent",
paid: "Paid",
overdue: "Overdue",
};
export function InvoiceList() {
const [searchTerm, setSearchTerm] = useState("");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [invoiceToDelete, setInvoiceToDelete] = useState<string | null>(null);
const { data: invoices, isLoading, refetch } = api.invoices.getAll.useQuery();
const deleteInvoice = api.invoices.delete.useMutation({
onSuccess: () => {
toast.success("Invoice deleted successfully");
refetch();
setDeleteDialogOpen(false);
setInvoiceToDelete(null);
},
onError: (error) => {
toast.error(error.message || "Failed to delete invoice");
},
});
const filteredInvoices = invoices?.filter(invoice =>
invoice.invoiceNumber.toLowerCase().includes(searchTerm.toLowerCase()) ||
invoice.client.name.toLowerCase().includes(searchTerm.toLowerCase())
) || [];
const handleDelete = (invoiceId: string) => {
setInvoiceToDelete(invoiceId);
setDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (invoiceToDelete) {
deleteInvoice.mutate({ id: invoiceToDelete });
}
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString();
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
};
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardHeader>
<div className="h-4 bg-muted rounded animate-pulse" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="h-3 bg-muted rounded animate-pulse" />
<div className="h-3 bg-muted rounded w-2/3 animate-pulse" />
</div>
</CardContent>
</Card>
))}
</div>
);
}
if (!invoices || invoices.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>No Invoices Yet</CardTitle>
<CardDescription>
Get started by creating your first invoice
</CardDescription>
</CardHeader>
<CardContent>
<Link href="/dashboard/invoices/new">
<Button className="w-full">
<Plus className="mr-2 h-4 w-4" />
Create Your First Invoice
</Button>
</Link>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
<div className="flex items-center space-x-4">
<div className="flex-1">
<Label htmlFor="search">Search invoices</Label>
<Input
id="search"
placeholder="Search by invoice number or client..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Link href="/dashboard/invoices/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Invoice
</Button>
</Link>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredInvoices.map((invoice) => (
<Card key={invoice.id}>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="truncate">{invoice.invoiceNumber}</span>
<div className="flex space-x-1">
<Link href={`/invoices/${invoice.id}`}>
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
</Link>
<Link href={`/invoices/${invoice.id}/edit`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(invoice.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardTitle>
<div className="flex items-center justify-between">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[invoice.status as keyof typeof statusColors]}`}>
{statusLabels[invoice.status as keyof typeof statusLabels]}
</span>
<span className="text-lg font-bold text-green-600">
{formatCurrency(invoice.totalAmount)}
</span>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center text-sm text-muted-foreground">
<User className="mr-2 h-4 w-4" />
{invoice.client.name}
</div>
<div className="flex items-center text-sm text-muted-foreground">
<Calendar className="mr-2 h-4 w-4" />
Due: {formatDate(invoice.dueDate)}
</div>
<div className="flex items-center text-sm text-muted-foreground">
<FileText className="mr-2 h-4 w-4" />
{invoice.items.length} item{invoice.items.length !== 1 ? 's' : ''}
</div>
</CardContent>
</Card>
))}
</div>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Invoice</DialogTitle>
<DialogDescription>
Are you sure you want to delete this invoice? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}