feat: polish invoice editor and viewer UI with custom NumberInput

component

- Create custom NumberInput component with increment/decrement buttons
- Add 0.25 step increments for hours and rates in invoice forms
- Implement emerald-themed styling with hover states and accessibility
- Add keyboard navigation (arrow keys) and proper ARIA support
- Condense invoice editor tax/totals section into efficient grid layout
- Update client dropdown to single-line format (name + email)
- Add fixed footer with floating action bar pattern matching business
  forms
- Redesign invoice viewer with better space utilization and visual
  hierarchy
- Maintain professional appearance and consistent design system
- Fix Next.js 15 params Promise handling across all invoice pages
- Resolve TypeScript compilation errors and type-only imports
This commit is contained in:
2025-07-15 00:29:02 -04:00
parent 89de059501
commit f331136090
79 changed files with 9944 additions and 4223 deletions
@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { toast } from "sonner";
import { generateInvoicePDF } from "~/lib/pdf-export";
import { Download, Loader2 } from "lucide-react";
interface Invoice {
id: string;
invoiceNumber: string;
issueDate: Date;
dueDate: Date;
status: string;
totalAmount: number;
taxRate: number;
notes?: string | null;
business?: {
name: string;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
website?: string | null;
taxId?: string | null;
} | null;
client: {
name: string;
email?: string | null;
phone?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country?: string | null;
};
items: Array<{
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
}>;
}
interface PDFDownloadButtonProps {
invoice: Invoice;
variant?: "button" | "menu" | "icon";
}
export function PDFDownloadButton({
invoice,
variant = "button",
}: PDFDownloadButtonProps) {
const [isGenerating, setIsGenerating] = useState(false);
const handleDownloadPDF = async () => {
if (isGenerating) return;
setIsGenerating(true);
try {
// Transform the invoice data to match the PDF interface
const pdfData = {
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
status: invoice.status,
totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate,
notes: invoice.notes,
business: invoice.business,
client: invoice.client,
items: invoice.items,
};
await generateInvoicePDF(pdfData);
toast.success("PDF downloaded successfully");
} catch (error) {
console.error("PDF generation error:", error);
toast.error(
error instanceof Error ? error.message : "Failed to generate PDF",
);
} finally {
setIsGenerating(false);
}
};
if (variant === "menu") {
return (
<button
onClick={handleDownloadPDF}
disabled={isGenerating}
className="hover:bg-accent flex w-full items-center gap-2 px-2 py-1.5 text-sm"
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{isGenerating ? "Generating..." : "Download PDF"}
</button>
);
}
if (variant === "icon") {
return (
<Button
onClick={handleDownloadPDF}
disabled={isGenerating}
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
);
}
return (
<Button
onClick={handleDownloadPDF}
disabled={isGenerating}
className="w-full justify-start"
variant="outline"
>
{isGenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{isGenerating ? "Generating..." : "Download PDF"}
</Button>
);
}
@@ -0,0 +1,770 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { useRouter, useParams } from "next/navigation";
import Link from "next/link";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { PageHeader } from "~/components/page-header";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { DatePicker } from "~/components/ui/date-picker";
import { FloatingActionBar } from "~/components/ui/floating-action-bar";
import { toast } from "sonner";
import {
ArrowLeft,
Save,
Plus,
Trash2,
FileText,
Building,
User,
Loader2,
Send,
DollarSign,
Hash,
Edit3,
Eye,
} from "lucide-react";
interface EditInvoicePageProps {}
interface InvoiceItem {
id?: string;
tempId: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
}
interface InvoiceFormData {
invoiceNumber: string;
businessId: string | undefined;
clientId: string;
issueDate: Date;
dueDate: Date;
notes: string;
taxRate: number;
items: InvoiceItem[];
status: "draft" | "sent" | "paid" | "overdue";
}
function InvoiceItemCard({
item,
index,
onUpdate,
onDelete,
_isLast,
}: {
item: InvoiceItem;
index: number;
onUpdate: (
index: number,
field: keyof InvoiceItem,
value: string | number | Date,
) => void;
onDelete: (index: number) => void;
_isLast: boolean;
}) {
const handleFieldChange = (
field: keyof InvoiceItem,
value: string | number | Date,
) => {
onUpdate(index, field, value);
};
return (
<Card className="border-border/50 border p-3 shadow-sm">
<div className="space-y-3">
{/* Header with item number and delete */}
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium">
Item {index + 1}
</span>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Item</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this line item? This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(index)}
className="bg-red-600 hover:bg-red-700"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{/* Description */}
<Textarea
value={item.description}
onChange={(e) => handleFieldChange("description", e.target.value)}
placeholder="Description of work..."
className="min-h-[48px] resize-none text-sm"
rows={1}
/>
{/* Date, Hours, Rate, Amount in compact grid */}
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
<div className="space-y-1">
<Label className="text-xs font-medium">Date</Label>
<DatePicker
date={item.date}
onDateChange={(date) =>
handleFieldChange("date", date ?? new Date())
}
className="[&>button]:h-8 [&>button]:text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium">Hours</Label>
<NumberInput
value={item.hours}
onChange={(value) => handleFieldChange("hours", value)}
min={0}
step={0.25}
placeholder="0"
className="text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium">Rate</Label>
<NumberInput
value={item.rate}
onChange={(value) => handleFieldChange("rate", value)}
min={0}
step={0.25}
placeholder="0.00"
prefix="$"
className="text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium">Amount</Label>
<div className="bg-muted/30 flex h-8 items-center rounded-md border px-2">
<span className="font-mono text-xs font-medium text-emerald-600">
${(item.hours * item.rate).toFixed(2)}
</span>
</div>
</div>
</div>
</div>
</Card>
);
}
function InvoiceEditor({ invoiceId }: { invoiceId: string }) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState<InvoiceFormData | null>(null);
// Floating action bar ref
const footerRef = useRef<HTMLDivElement>(null);
// Queries
const { data: invoice, isLoading: invoiceLoading } =
api.invoices.getById.useQuery({
id: invoiceId,
});
const { data: clients, isLoading: clientsLoading } =
api.clients.getAll.useQuery();
const { data: businesses, isLoading: businessesLoading } =
api.businesses.getAll.useQuery();
// Mutations
const updateInvoice = api.invoices.update.useMutation({
onSuccess: () => {
toast.success("Invoice updated successfully");
router.push(`/dashboard/invoices/${invoiceId}`);
},
onError: (error) => {
toast.error(error.message || "Failed to update invoice");
},
});
// Initialize form data when invoice loads
useEffect(() => {
if (invoice) {
const transformedItems: InvoiceItem[] =
invoice.items?.map((item, index) => ({
id: item.id,
tempId: item.id || `temp-${index}`,
date: item.date || new Date(),
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.amount,
})) || [];
setFormData({
invoiceNumber: invoice.invoiceNumber,
businessId: invoice.businessId ?? undefined,
clientId: invoice.clientId,
issueDate: new Date(invoice.issueDate),
dueDate: new Date(invoice.dueDate),
notes: invoice.notes ?? "",
taxRate: invoice.taxRate,
items: transformedItems ?? [],
status: invoice.status as "draft" | "sent" | "paid" | "overdue",
});
}
}, [invoice]);
const handleItemUpdate = (
index: number,
field: keyof InvoiceItem,
value: string | number | Date,
) => {
if (!formData) return;
const updatedItems = [...formData.items];
const currentItem = updatedItems[index];
if (currentItem) {
updatedItems[index] = { ...currentItem, [field]: value };
// Recalculate amount for hours or rate changes
if (field === "hours" || field === "rate") {
const updatedItem = updatedItems[index];
if (!updatedItem) return;
updatedItem.amount = updatedItem.hours * updatedItem.rate;
}
}
setFormData({ ...formData, items: updatedItems });
};
const handleItemDelete = (index: number) => {
if (!formData) return;
if (formData.items.length === 1) {
toast.error("At least one line item is required");
return;
}
const updatedItems = formData.items.filter((_, i) => i !== index);
setFormData({ ...formData, items: updatedItems });
};
const handleAddItem = () => {
if (!formData) return;
const newItem: InvoiceItem = {
tempId: `item-${Date.now()}`,
date: new Date(),
description: "",
hours: 0,
rate: 0,
amount: 0,
};
setFormData({
...formData,
items: [...formData.items, newItem],
});
};
const handleSaveDraft = async () => {
await handleSave("draft");
};
const handleUpdateInvoice = async () => {
await handleSave(formData?.status ?? "draft");
};
const handleSave = async (status: "draft" | "sent" | "paid" | "overdue") => {
if (!formData) return;
// Validation
if (!formData.clientId) {
toast.error("Please select a client");
return;
}
if (formData.items.length === 0) {
toast.error("At least one line item is required");
return;
}
// Check if all items have required fields
const invalidItems = formData.items.some(
(item) => !item.description.trim() || item.hours <= 0 || item.rate <= 0,
);
if (invalidItems) {
toast.error("All line items must have description, hours, and rate");
return;
}
setIsLoading(true);
try {
await updateInvoice.mutateAsync({
id: invoiceId,
...formData,
businessId: formData.businessId ?? undefined,
status,
});
} finally {
setIsLoading(false);
}
};
const calculateSubtotal = () => {
if (!formData) return 0;
return formData.items.reduce((sum, item) => sum + item.amount, 0);
};
const calculateTax = () => {
if (!formData) return 0;
return (calculateSubtotal() * formData.taxRate) / 100;
};
const calculateTotal = () => {
return calculateSubtotal() + calculateTax();
};
const isFormValid = () => {
if (!formData) return false;
return (
formData.clientId &&
formData.items.length > 0 &&
formData.items.every(
(item) => item.description.trim() && item.hours > 0 && item.rate > 0,
)
);
};
const getStatusBadge = (status: string) => {
switch (status) {
case "draft":
return <Badge variant="secondary">Draft</Badge>;
case "sent":
return <Badge variant="default">Sent</Badge>;
case "paid":
return (
<Badge variant="outline" className="border-green-500 text-green-700">
Paid
</Badge>
);
case "overdue":
return <Badge variant="destructive">Overdue</Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
}
};
if (invoiceLoading || clientsLoading || businessesLoading || !formData) {
return (
<div className="space-y-6">
<PageHeader
title="Edit Invoice"
description="Loading invoice data..."
variant="gradient"
/>
<Card className="shadow-xl">
<CardContent className="flex items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" />
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title={`Edit Invoice`}
description="Update invoice details and line items"
variant="gradient"
>
<div className="flex items-center gap-2">
{getStatusBadge(formData.status)}
<Link href={`/dashboard/invoices/${invoiceId}`}>
<Button variant="outline" size="sm">
<Eye className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">View Invoice</span>
<span className="sm:hidden">View</span>
</Button>
</Link>
</div>
</PageHeader>
<div className="space-y-6">
{/* Invoice Header */}
<Card className="shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-emerald-600" />
Invoice Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label className="text-sm font-medium">Invoice Number</Label>
<div className="bg-muted/30 flex h-10 items-center rounded-md border px-3">
<Hash className="text-muted-foreground mr-2 h-4 w-4" />
<span className="font-mono text-sm font-medium">
{formData.invoiceNumber}
</span>
</div>
</div>
<DatePicker
date={formData.issueDate}
onDateChange={(date) =>
setFormData({
...formData,
issueDate: date ?? new Date(),
})
}
label="Issue Date"
required
/>
<DatePicker
date={formData.dueDate}
onDateChange={(date) =>
setFormData({
...formData,
dueDate: date ?? new Date(),
})
}
label="Due Date"
required
/>
</div>
</CardContent>
</Card>
{/* Business & Client */}
<Card className="shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building className="h-5 w-5 text-emerald-600" />
Business & Client
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-sm font-medium">From Business</Label>
<div className="relative">
<Building className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Select
value={formData.businessId ?? ""}
onValueChange={(value) =>
setFormData({
...formData,
businessId: value || undefined,
})
}
>
<SelectTrigger className="pl-9">
<SelectValue placeholder="Select business..." />
</SelectTrigger>
<SelectContent>
{businesses?.map((business) => (
<SelectItem key={business.id} value={business.id}>
<div className="flex items-center gap-2">
<span>{business.name}</span>
{business.isDefault && (
<Badge variant="secondary" className="text-xs">
Default
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Client *</Label>
<div className="relative">
<User className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Select
value={formData.clientId}
onValueChange={(value) =>
setFormData({ ...formData, clientId: value })
}
>
<SelectTrigger className="pl-9">
<SelectValue placeholder="Select client..." />
</SelectTrigger>
<SelectContent>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
<div className="flex w-full items-center justify-between">
<span className="font-medium">{client.name}</span>
<span className="text-muted-foreground ml-2 text-sm">
{client.email}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Line Items */}
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Edit3 className="h-5 w-5 text-emerald-600" />
Line Items ({formData.items.length})
</CardTitle>
<Button
onClick={handleAddItem}
type="button"
variant="outline"
size="sm"
className="shrink-0"
>
<Plus className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Add Item</span>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{formData.items.map((item, index) => (
<InvoiceItemCard
key={item.tempId}
item={item}
index={index}
onUpdate={handleItemUpdate}
onDelete={handleItemDelete}
_isLast={index === formData.items.length - 1}
/>
))}
</CardContent>
</Card>
{/* Notes & Totals */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
{/* Notes */}
<Card className="shadow-lg lg:col-span-3">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-emerald-600" />
Notes
</CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={formData.notes}
onChange={(e) =>
setFormData({ ...formData, notes: e.target.value })
}
placeholder="Payment terms, additional notes..."
rows={4}
className="resize-none"
/>
</CardContent>
</Card>
{/* Tax & Totals */}
<Card className="shadow-lg lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="h-5 w-5 text-emerald-600" />
Tax & Totals
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Tax Rate (%)</Label>
<NumberInput
value={formData.taxRate}
onChange={(value) =>
setFormData({
...formData,
taxRate: value,
})
}
min={0}
max={100}
step={0.01}
placeholder="0.00"
suffix="%"
className="h-9"
/>
</div>
<div className="bg-muted/20 rounded-lg border p-3">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Subtotal:</span>
<span className="font-mono font-medium">
${calculateSubtotal().toFixed(2)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Tax ({formData.taxRate}%):
</span>
<span className="font-mono font-medium">
${calculateTax().toFixed(2)}
</span>
</div>
<Separator />
<div className="flex justify-between text-base font-bold">
<span>Total:</span>
<span className="font-mono text-emerald-600">
${calculateTotal().toFixed(2)}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Form Actions - original position */}
<div
ref={footerRef}
className="border-border/40 bg-background/60 flex items-center justify-between rounded-2xl border p-4 shadow-lg backdrop-blur-xl backdrop-saturate-150"
>
<p className="text-muted-foreground text-sm">
Editing invoice {formData.invoiceNumber}
</p>
<div className="flex items-center gap-3">
<Link href={`/dashboard/invoices/${invoiceId}`}>
<Button
variant="outline"
disabled={isLoading}
className="border-border/40 hover:bg-accent/50"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Cancel
</Button>
</Link>
<Button
onClick={handleSaveDraft}
disabled={isLoading || !isFormValid()}
variant="outline"
className="border-border/40 hover:bg-accent/50"
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Draft
</Button>
<Button
onClick={handleUpdateInvoice}
disabled={isLoading || !isFormValid()}
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Update Invoice
</Button>
</div>
</div>
</div>
<FloatingActionBar
triggerRef={footerRef}
title={`Editing invoice ${formData.invoiceNumber}`}
>
<Link href={`/dashboard/invoices/${invoiceId}`}>
<Button
variant="outline"
disabled={isLoading}
className="border-border/40 hover:bg-accent/50"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Cancel
</Button>
</Link>
<Button
onClick={handleSaveDraft}
disabled={isLoading || !isFormValid()}
variant="outline"
className="border-border/40 hover:bg-accent/50"
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Draft
</Button>
<Button
onClick={handleUpdateInvoice}
disabled={isLoading || !isFormValid()}
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Update Invoice
</Button>
</FloatingActionBar>
</div>
);
}
export default function EditInvoicePage() {
const params = useParams();
const invoiceId = Array.isArray(params?.id) ? params.id[0] : params?.id;
if (!invoiceId) return null;
return <InvoiceEditor invoiceId={invoiceId} />;
}
+520 -58
View File
@@ -1,72 +1,534 @@
import { api, HydrateClient } from "~/trpc/server";
import { InvoiceView } from "~/components/invoice-view";
import { InvoiceForm } from "~/components/invoice-form";
import Link from "next/link";
import { Suspense } from "react";
import { notFound } from "next/navigation";
import { Edit, Eye, ArrowLeft } from "lucide-react";
import { UnifiedInvoicePage } from "./_components/unified-invoice-page";
import Link from "next/link";
import { api, HydrateClient } from "~/trpc/server";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
import { Separator } from "~/components/ui/separator";
import { PageHeader } from "~/components/page-header";
import { PDFDownloadButton } from "./_components/pdf-download-button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import {
ArrowLeft,
Edit,
Send,
Copy,
MoreHorizontal,
CheckCircle,
Clock,
Calendar,
FileText,
Building,
User,
DollarSign,
Hash,
MapPin,
Mail,
Phone,
} from "lucide-react";
interface InvoicePageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ mode?: string }>;
}
export default async function InvoicePage({
params,
searchParams,
}: InvoicePageProps) {
const { id } = await params;
const { mode = "view" } = await searchParams;
function InvoiceStatusBadge({
status,
dueDate,
}: {
status: string;
dueDate: Date;
}) {
const getStatus = (): "draft" | "sent" | "paid" | "overdue" => {
if (status === "paid") return "paid";
if (status === "draft") return "draft";
if (status === "sent") {
const due = new Date(dueDate);
return due < new Date() ? "overdue" : "sent";
}
return "draft";
};
const actualStatus = getStatus();
const icons = {
draft: FileText,
sent: Clock,
paid: CheckCircle,
overdue: Clock,
};
const Icon = icons[actualStatus];
return (
<div>
<div className="mb-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
Invoice Details
</h1>
<p className="mt-1 text-lg text-gray-600 dark:text-gray-300">
View and manage invoice information.
</p>
</div>
<StatusBadge status={actualStatus} className="flex items-center gap-1">
<Icon className="h-3 w-3" />
{actualStatus.charAt(0).toUpperCase() + actualStatus.slice(1)}
</StatusBadge>
);
}
<div className="relative flex rounded-lg border border-gray-200 bg-gray-100 p-1 dark:border-gray-700 dark:bg-gray-800">
<div
className={`absolute top-1 bottom-1 rounded-md bg-white shadow-sm transition-all duration-300 ease-in-out dark:bg-gray-700 ${
mode === "view" ? "left-1 w-10" : "left-11 w-10"
}`}
/>
<Link
href={`/dashboard/invoices/${id}?mode=view`}
className={`relative z-10 rounded-md px-3 py-2 transition-all duration-200 ${
mode === "view"
? "text-emerald-600"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300"
}`}
>
<Eye className="h-4 w-4" />
</Link>
<Link
href={`/dashboard/invoices/${id}?mode=edit`}
className={`relative z-10 rounded-md px-3 py-2 transition-all duration-200 ${
mode === "edit"
? "text-emerald-600"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300"
}`}
>
<Edit className="h-4 w-4" />
</Link>
</div>
</div>
async function InvoiceDetails({ invoiceId }: { invoiceId: string }) {
const invoice = await api.invoices.getById({ id: invoiceId });
<div className="mt-4">
<HydrateClient>
<UnifiedInvoicePage invoiceId={id} mode={mode} />
</HydrateClient>
</div>
if (!invoice) {
notFound();
}
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
}).format(new Date(date));
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
const subtotal =
invoice.items?.reduce((sum, item) => sum + item.hours * item.rate, 0) || 0;
const taxAmount = (subtotal * (invoice.taxRate || 0)) / 100;
const total = subtotal + taxAmount;
return (
<div className="space-y-6">
{/* Invoice Header */}
<Card className="border-0 shadow-lg">
<CardContent className="p-6">
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
{/* Invoice Info */}
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="rounded-lg bg-emerald-100 p-3 dark:bg-emerald-900/30">
<Hash className="h-6 w-6 text-emerald-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-emerald-700 dark:text-emerald-400">
{invoice.invoiceNumber}
</h1>
<p className="text-muted-foreground text-sm">Invoice</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="flex items-center gap-2">
<Calendar className="text-muted-foreground h-4 w-4" />
<div>
<p className="text-muted-foreground text-xs font-medium">
Issued
</p>
<p className="text-sm font-semibold">
{formatDate(invoice.issueDate)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Clock className="text-muted-foreground h-4 w-4" />
<div>
<p className="text-muted-foreground text-xs font-medium">
Due
</p>
<p className="text-sm font-semibold">
{formatDate(invoice.dueDate)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<DollarSign className="text-muted-foreground h-4 w-4" />
<div>
<p className="text-muted-foreground text-xs font-medium">
Amount
</p>
<p className="text-sm font-semibold text-emerald-600">
{formatCurrency(total)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<FileText className="text-muted-foreground h-4 w-4" />
<div>
<p className="text-muted-foreground text-xs font-medium">
Status
</p>
<InvoiceStatusBadge
status={invoice.status}
dueDate={invoice.dueDate}
/>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2 sm:flex-row lg:flex-col">
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button className="w-full">
<Edit className="mr-2 h-4 w-4" />
Edit Invoice
</Button>
</Link>
<PDFDownloadButton invoice={invoice} variant="button" />
</div>
</div>
</CardContent>
</Card>
{/* Business & Client Info */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* From Business */}
<Card className="border-0 shadow-md">
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-lg">
<Building className="h-4 w-4 text-emerald-600" />
From
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{invoice.business ? (
<>
<div>
<p className="font-semibold">{invoice.business.name}</p>
</div>
<div className="space-y-1">
{invoice.business.email && (
<div className="flex items-center gap-2 text-sm">
<Mail className="text-muted-foreground h-3 w-3" />
<span className="text-muted-foreground">
{invoice.business.email}
</span>
</div>
)}
{invoice.business.phone && (
<div className="flex items-center gap-2 text-sm">
<Phone className="text-muted-foreground h-3 w-3" />
<span className="text-muted-foreground">
{invoice.business.phone}
</span>
</div>
)}
{invoice.business.addressLine1 && (
<div className="flex items-start gap-2 text-sm">
<MapPin className="text-muted-foreground mt-0.5 h-3 w-3" />
<div className="text-muted-foreground">
<p>{invoice.business.addressLine1}</p>
{invoice.business.addressLine2 && (
<p>{invoice.business.addressLine2}</p>
)}
<p>
{[
invoice.business.city,
invoice.business.state,
invoice.business.postalCode,
]
.filter(Boolean)
.join(", ")}
</p>
{invoice.business.country && (
<p>{invoice.business.country}</p>
)}
</div>
</div>
)}
</div>
</>
) : (
<p className="text-muted-foreground text-sm italic">
No business information
</p>
)}
</CardContent>
</Card>
{/* To Client */}
<Card className="border-0 shadow-md">
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-lg">
<User className="h-4 w-4 text-emerald-600" />
Bill To
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="font-semibold">{invoice.client.name}</p>
</div>
<div className="space-y-1">
{invoice.client.email && (
<div className="flex items-center gap-2 text-sm">
<Mail className="text-muted-foreground h-3 w-3" />
<span className="text-muted-foreground">
{invoice.client.email}
</span>
</div>
)}
{invoice.client.phone && (
<div className="flex items-center gap-2 text-sm">
<Phone className="text-muted-foreground h-3 w-3" />
<span className="text-muted-foreground">
{invoice.client.phone}
</span>
</div>
)}
{invoice.client.addressLine1 && (
<div className="flex items-start gap-2 text-sm">
<MapPin className="text-muted-foreground mt-0.5 h-3 w-3" />
<div className="text-muted-foreground">
<p>{invoice.client.addressLine1}</p>
{invoice.client.addressLine2 && (
<p>{invoice.client.addressLine2}</p>
)}
<p>
{[
invoice.client.city,
invoice.client.state,
invoice.client.postalCode,
]
.filter(Boolean)
.join(", ")}
</p>
{invoice.client.country && <p>{invoice.client.country}</p>}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* Line Items */}
<Card className="border-0 shadow-lg">
<CardHeader className="border-b">
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-emerald-600" />
Line Items ({invoice.items?.length || 0})
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
{invoice.items && invoice.items.length > 0 ? (
<div className="space-y-0">
{/* Header - Hidden on mobile */}
<div className="border-muted/30 bg-muted/20 hidden grid-cols-12 gap-4 border-b px-6 py-3 text-sm font-medium md:grid">
<div className="col-span-2">Date</div>
<div className="col-span-5">Description</div>
<div className="col-span-2 text-right">Hours</div>
<div className="col-span-2 text-right">Rate</div>
<div className="col-span-1 text-right">Amount</div>
</div>
{/* Items */}
{invoice.items.map((item, index) => (
<div
key={index}
className="border-muted/30 grid grid-cols-1 gap-2 border-b px-6 py-4 last:border-b-0 md:grid-cols-12 md:items-center md:gap-4"
>
{/* Mobile Layout */}
<div className="md:hidden">
<div className="mb-2 flex items-start justify-between">
<p className="font-medium">{item.description}</p>
<span className="font-mono text-sm font-semibold text-emerald-600">
{formatCurrency(item.hours * item.rate)}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<div>
<span className="text-muted-foreground text-xs">
Date
</span>
<p>{formatDate(item.date)}</p>
</div>
<div>
<span className="text-muted-foreground text-xs">
Hours
</span>
<p className="font-mono">{item.hours}</p>
</div>
<div>
<span className="text-muted-foreground text-xs">
Rate
</span>
<p className="font-mono">
{formatCurrency(item.rate)}
</p>
</div>
</div>
</div>
{/* Desktop Layout */}
<div className="text-muted-foreground col-span-2 hidden text-sm md:block">
{formatDate(item.date)}
</div>
<div className="col-span-5 hidden font-medium md:block">
{item.description}
</div>
<div className="col-span-2 hidden text-right font-mono text-sm md:block">
{item.hours}
</div>
<div className="col-span-2 hidden text-right font-mono text-sm md:block">
{formatCurrency(item.rate)}
</div>
<div className="col-span-1 hidden text-right font-mono font-semibold text-emerald-600 md:block">
{formatCurrency(item.hours * item.rate)}
</div>
</div>
))}
</div>
) : (
<div className="text-muted-foreground py-12 text-center">
<FileText className="mx-auto mb-2 h-8 w-8" />
<p>No line items found</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Totals & Notes */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Notes */}
{invoice.notes && (
<Card className="border-0 shadow-md lg:col-span-2">
<CardHeader className="pb-4">
<CardTitle className="text-lg">Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground leading-relaxed">
{invoice.notes}
</p>
</CardContent>
</Card>
)}
{/* Totals */}
<Card
className={`border-0 shadow-md ${!invoice.notes ? "lg:col-start-3" : ""}`}
>
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-lg">
<DollarSign className="h-4 w-4 text-emerald-600" />
Total
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Subtotal:</span>
<span className="font-mono">{formatCurrency(subtotal)}</span>
</div>
{invoice.taxRate > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Tax ({invoice.taxRate}%):
</span>
<span className="font-mono">{formatCurrency(taxAmount)}</span>
</div>
)}
<Separator />
<div className="flex justify-between text-lg font-bold">
<span>Total:</span>
<span className="font-mono text-emerald-600">
{formatCurrency(total)}
</span>
</div>
</div>
{/* Status Actions */}
<div className="pt-2">
{invoice.status === "draft" && (
<Button className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700">
<Send className="mr-2 h-4 w-4" />
Send Invoice
</Button>
)}
{invoice.status === "sent" && (
<Button className="w-full bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700">
<CheckCircle className="mr-2 h-4 w-4" />
Mark as Paid
</Button>
)}
{(invoice.status === "paid" || invoice.status === "overdue") && (
<div className="text-center">
<InvoiceStatusBadge
status={invoice.status}
dueDate={invoice.dueDate}
/>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
);
}
export default async function InvoicePage({ params }: InvoicePageProps) {
const { id } = await params;
return (
<div className="space-y-6">
<PageHeader
title="Invoice Details"
description="View and manage invoice information"
variant="gradient"
>
<div className="flex items-center gap-2">
<Link href="/dashboard/invoices">
<Button variant="outline" size="sm">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/dashboard/invoices/${id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Invoice
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Send className="mr-2 h-4 w-4" />
Download PDF
</DropdownMenuItem>
<DropdownMenuItem>
<Send className="mr-2 h-4 w-4" />
Send Invoice
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</PageHeader>
<HydrateClient>
<Suspense fallback={<div>Loading invoice details...</div>}>
<InvoiceDetails invoiceId={id} />
</Suspense>
</HydrateClient>
</div>
);
}
@@ -0,0 +1,247 @@
"use client";
import Link from "next/link";
import type { ColumnDef } from "@tanstack/react-table";
import { Button } from "~/components/ui/button";
import { StatusBadge, type StatusType } from "~/components/ui/status-badge";
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
import { DataTable, DataTableColumnHeader } from "~/components/ui/data-table";
import { EmptyState } from "~/components/ui/page-layout";
import { Plus, FileText, Eye, Edit } from "lucide-react";
// Type for invoice data
interface Invoice {
id: string;
invoiceNumber: string;
clientId: string;
businessId: string | null;
issueDate: Date;
dueDate: Date;
status: string;
totalAmount: number;
taxRate: number;
notes: string | null;
createdById: string;
createdAt: Date;
updatedAt: Date | null;
client?: {
id: string;
name: string;
email: string | null;
phone: string | null;
} | null;
business?: {
id: string;
name: string;
email: string | null;
phone: string | null;
} | null;
items?: Array<{
id: string;
invoiceId: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
position: number;
createdAt: Date;
}> | null;
}
interface InvoicesDataTableProps {
invoices: Invoice[];
}
const getStatusType = (invoice: Invoice): StatusType => {
if (invoice.status === "paid") return "paid";
if (invoice.status === "draft") return "draft";
if (invoice.status === "sent") {
const dueDate = new Date(invoice.dueDate);
return dueDate < new Date() ? "overdue" : "sent";
}
return "draft";
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
}).format(new Date(date));
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
};
const columns: ColumnDef<Invoice>[] = [
{
accessorKey: "invoiceNumber",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Invoice" />
),
cell: ({ row }) => {
const invoice = row.original;
return (
<div className="flex items-center gap-3">
<div className="bg-status-success-muted hidden rounded-lg p-2 sm:flex">
<FileText className="text-status-success h-4 w-4" />
</div>
<div className="min-w-0">
<p className="truncate font-medium">{invoice.invoiceNumber}</p>
<p className="text-muted-foreground truncate text-sm">
{invoice.items?.length || 0} items
</p>
</div>
</div>
);
},
},
{
accessorKey: "client.name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Client" />
),
cell: ({ row }) => {
const invoice = row.original;
return (
<div className="min-w-0">
<p className="truncate font-medium">{invoice.client?.name || "—"}</p>
<p className="text-muted-foreground truncate text-sm">
{invoice.client?.email || "—"}
</p>
</div>
);
},
},
{
accessorKey: "issueDate",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Issue Date" />
),
cell: ({ row }) => formatDate(row.getValue("issueDate")),
meta: {
headerClassName: "hidden md:table-cell",
cellClassName: "hidden md:table-cell",
},
},
{
accessorKey: "dueDate",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Due Date" />
),
cell: ({ row }) => formatDate(row.getValue("dueDate")),
meta: {
headerClassName: "hidden lg:table-cell",
cellClassName: "hidden lg:table-cell",
},
},
{
accessorKey: "totalAmount",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Amount" />
),
cell: ({ row }) => {
const amount = row.getValue("totalAmount") as number;
return <p className="font-semibold">{formatCurrency(amount)}</p>;
},
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const invoice = row.original;
return <StatusBadge status={getStatusType(invoice)} />;
},
filterFn: (row, id, value) => {
const invoice = row.original;
const status = getStatusType(invoice);
return value.includes(status);
},
},
{
id: "actions",
cell: ({ row }) => {
const invoice = row.original;
return (
<div className="flex items-center justify-end gap-1">
<Link href={`/dashboard/invoices/${invoice.id}`}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Eye className="h-3.5 w-3.5" />
</Button>
</Link>
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Edit className="h-3.5 w-3.5" />
</Button>
</Link>
{invoice.items && invoice.client && (
<PDFDownloadButton
invoice={{
id: invoice.id,
invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
status: invoice.status,
totalAmount: invoice.totalAmount,
taxRate: invoice.taxRate,
notes: invoice.notes,
business: invoice.business
? {
name: invoice.business.name,
email: invoice.business.email,
phone: invoice.business.phone,
}
: null,
client: {
name: invoice.client.name,
email: invoice.client.email,
phone: invoice.client.phone,
},
items: invoice.items.map((item) => ({
date: item.date,
description: item.description,
hours: item.hours,
rate: item.rate,
amount: item.amount,
})),
}}
variant="icon"
/>
)}
</div>
);
},
},
];
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
const filterableColumns = [
{
id: "status",
title: "Status",
options: [
{ label: "Draft", value: "draft" },
{ label: "Sent", value: "sent" },
{ label: "Paid", value: "paid" },
{ label: "Overdue", value: "overdue" },
],
},
];
return (
<DataTable
columns={columns}
data={invoices}
searchKey="invoiceNumber"
searchPlaceholder="Search invoices..."
filterableColumns={filterableColumns}
/>
);
}
@@ -1,15 +0,0 @@
"use client";
import { api } from "~/trpc/react";
import { UniversalTable } from "~/components/ui/universal-table";
import { TableSkeleton } from "~/components/ui/skeleton";
export function InvoicesTable() {
const { isLoading } = api.invoices.getAll.useQuery();
if (isLoading) {
return <TableSkeleton rows={8} />;
}
return <UniversalTable resource="invoices" />;
}
+462 -11
View File
@@ -1,20 +1,471 @@
import { Suspense } from "react";
import Link from "next/link";
import { HydrateClient } from "~/trpc/server";
import { CSVImportPage } from "~/components/csv-import-page";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { PageHeader } from "~/components/page-header";
import {
ArrowLeft,
Upload,
FileText,
Download,
CheckCircle,
AlertCircle,
Info,
Zap,
FileSpreadsheet,
Eye,
RefreshCw,
} from "lucide-react";
// Import Statistics Component
function ImportStats() {
const stats = [
{
title: "Supported Formats",
value: "CSV",
icon: FileSpreadsheet,
color: "text-blue-600",
bgColor: "bg-blue-50 dark:bg-blue-900/20",
description: "Excel & Google Sheets exports",
},
{
title: "Max File Size",
value: "10MB",
icon: Upload,
color: "text-green-600",
bgColor: "bg-green-50 dark:bg-green-900/20",
description: "Up to 1000 invoices",
},
{
title: "Processing Time",
value: "< 1min",
icon: Zap,
color: "text-purple-600",
bgColor: "bg-purple-50 dark:bg-purple-900/20",
description: "Average processing speed",
},
{
title: "Success Rate",
value: "99.9%",
icon: CheckCircle,
color: "text-emerald-600",
bgColor: "bg-emerald-50 dark:bg-emerald-900/20",
description: "Import success rate",
},
];
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<Card
key={stat.title}
className="border-0 shadow-md transition-shadow hover:shadow-lg"
>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-2">
<p className="text-muted-foreground text-sm font-medium">
{stat.title}
</p>
<p className="text-2xl font-bold">{stat.value}</p>
<p className="text-muted-foreground text-xs">
{stat.description}
</p>
</div>
<div className={`rounded-full p-3 ${stat.bgColor}`}>
<Icon className={`h-6 w-6 ${stat.color}`} />
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
);
}
// File Upload Component
function FileUploadArea() {
return (
<Card className="border-0 shadow-lg">
<CardHeader className="border-b">
<CardTitle className="flex items-center gap-2 text-lg">
<Upload className="h-5 w-5 text-emerald-600" />
Upload CSV File
</CardTitle>
</CardHeader>
<CardContent className="p-8">
<div className="mx-auto max-w-xl">
{/* Drop Zone */}
<div className="rounded-lg border-2 border-dashed border-emerald-300 bg-emerald-50/50 p-12 text-center transition-colors hover:border-emerald-400 hover:bg-emerald-50 dark:border-emerald-700 dark:bg-emerald-900/10 dark:hover:bg-emerald-900/20">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/30">
<Upload className="h-8 w-8 text-emerald-600" />
</div>
<h3 className="mb-2 text-lg font-semibold">
Drop your CSV file here
</h3>
<p className="text-muted-foreground mb-4">
or click to browse and select a file
</p>
<Button
type="button"
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700"
>
<Upload className="mr-2 h-4 w-4" />
Choose File
</Button>
<p className="text-muted-foreground mt-4 text-sm">
Maximum file size: 10MB Supported format: CSV
</p>
</div>
{/* Upload Progress (hidden by default) */}
<div className="mt-6 hidden">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">Uploading...</span>
<span className="text-sm text-emerald-600">75%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full bg-gradient-to-r from-emerald-600 to-teal-600 transition-all duration-300"
style={{ width: "75%" }}
></div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
// CSV Format Instructions
function FormatInstructions() {
return (
<div className="grid gap-6 lg:grid-cols-2">
{/* Required Format */}
<Card className="border-0 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="h-5 w-5 text-blue-600" />
Required CSV Format
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg bg-gray-50 p-4 dark:bg-gray-800/50">
<p className="font-mono text-sm text-gray-700 dark:text-gray-300">
client_name,client_email,invoice_number,issue_date,due_date,description,hours,rate,tax_rate
</p>
</div>
<div className="space-y-3">
<h4 className="font-semibold">Required Columns:</h4>
<div className="grid gap-2">
{[
{ field: "client_name", desc: "Full name of the client" },
{ field: "client_email", desc: "Client email address" },
{ field: "invoice_number", desc: "Unique invoice identifier" },
{ field: "issue_date", desc: "Date issued (YYYY-MM-DD)" },
{ field: "due_date", desc: "Payment due date (YYYY-MM-DD)" },
{ field: "description", desc: "Work description" },
{ field: "hours", desc: "Number of hours worked" },
{ field: "rate", desc: "Hourly rate (decimal)" },
].map((col) => (
<div key={col.field} className="flex items-start gap-3">
<Badge variant="outline" className="text-xs">
{col.field}
</Badge>
<span className="text-muted-foreground text-sm">
{col.desc}
</span>
</div>
))}
</div>
</div>
<div className="pt-2">
<h4 className="mb-2 font-semibold">Optional Columns:</h4>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary" className="text-xs">
tax_rate
</Badge>
<Badge variant="secondary" className="text-xs">
notes
</Badge>
<Badge variant="secondary" className="text-xs">
client_phone
</Badge>
</div>
</div>
</CardContent>
</Card>
{/* Sample Data & Download */}
<Card className="border-0 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Download className="h-5 w-5 text-green-600" />
Sample Template
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground">
Download our sample CSV template to see the exact format required
for importing invoices.
</p>
<div className="rounded-lg bg-green-50 p-4 dark:bg-green-900/20">
<div className="flex items-start gap-3">
<Info className="mt-0.5 h-5 w-5 text-green-600" />
<div>
<p className="text-sm font-medium text-green-800 dark:text-green-400">
Pro Tip
</p>
<p className="text-sm text-green-700 dark:text-green-300">
The template includes sample data and formatting examples to
help you get started quickly.
</p>
</div>
</div>
</div>
<div className="space-y-3">
<Button variant="outline" className="w-full justify-start">
<Download className="mr-2 h-4 w-4" />
Download Sample CSV Template
</Button>
<Button variant="outline" className="w-full justify-start">
<Eye className="mr-2 h-4 w-4" />
View Template in Browser
</Button>
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-semibold">Sample Row:</h4>
<div className="rounded-lg bg-gray-50 p-3 dark:bg-gray-800/50">
<p className="font-mono text-xs break-all text-gray-600 dark:text-gray-400">
"Acme
Corp","john@acme.com","INV-001","2024-01-15","2024-02-14","Web
development work","40","75.00","8.5"
</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
// Important Notes Section
function ImportantNotes() {
return (
<Card className="border-0 border-l-4 border-l-amber-500 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<AlertCircle className="h-5 w-5 text-amber-600" />
Important Notes
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<h4 className="mb-2 font-semibold">Before Importing:</h4>
<ul className="text-muted-foreground space-y-1 text-sm">
<li> Ensure all client emails are valid</li>
<li> Use YYYY-MM-DD format for dates</li>
<li> Invoice numbers must be unique</li>
<li> Rates should be in decimal format (e.g., 75.50)</li>
</ul>
</div>
<div>
<h4 className="mb-2 font-semibold">What Happens:</h4>
<ul className="text-muted-foreground space-y-1 text-sm">
<li> New clients will be created automatically</li>
<li> Existing clients will be matched by email</li>
<li> Invoices will be created in "draft" status</li>
<li> You can review and edit before sending</li>
</ul>
</div>
</div>
</CardContent>
</Card>
);
}
// Import History Component
function ImportHistory() {
const mockHistory = [
{
id: "1",
filename: "january_invoices.csv",
date: "2024-01-15",
status: "completed",
imported: 25,
errors: 0,
},
{
id: "2",
filename: "december_invoices.csv",
date: "2024-01-01",
status: "completed",
imported: 18,
errors: 2,
},
{
id: "3",
filename: "november_invoices.csv",
date: "2023-12-01",
status: "completed",
imported: 32,
errors: 1,
},
];
const getStatusBadge = (status: string) => {
if (status === "completed") {
return (
<Badge className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
<CheckCircle className="mr-1 h-3 w-3" />
Completed
</Badge>
);
}
if (status === "processing") {
return (
<Badge className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
<RefreshCw className="mr-1 h-3 w-3" />
Processing
</Badge>
);
}
return (
<Badge variant="outline">
<AlertCircle className="mr-1 h-3 w-3" />
Failed
</Badge>
);
};
return (
<Card className="border-0 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="h-5 w-5 text-purple-600" />
Recent Imports
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<tr className="border-b">
<th className="p-4 text-left text-sm font-medium">File</th>
<th className="p-4 text-left text-sm font-medium">Date</th>
<th className="p-4 text-left text-sm font-medium">Status</th>
<th className="p-4 text-right text-sm font-medium">Imported</th>
<th className="p-4 text-right text-sm font-medium">Errors</th>
<th className="p-4 text-center text-sm font-medium">Actions</th>
</tr>
</thead>
<tbody>
{mockHistory.map((item) => (
<tr
key={item.id}
className="hover:bg-muted/20 border-b transition-colors"
>
<td className="p-4">
<div className="flex items-center gap-3">
<div className="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
<FileSpreadsheet className="h-4 w-4 text-purple-600" />
</div>
<span className="font-medium">{item.filename}</span>
</div>
</td>
<td className="p-4 text-sm">
{new Date(item.date).toLocaleDateString()}
</td>
<td className="p-4">{getStatusBadge(item.status)}</td>
<td className="p-4 text-right font-medium">
{item.imported}
</td>
<td className="p-4 text-right">
{item.errors > 0 ? (
<span className="text-red-600">{item.errors}</span>
) : (
<span className="text-muted-foreground">0</span>
)}
</td>
<td className="p-4 text-center">
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{mockHistory.length === 0 && (
<div className="py-8 text-center">
<p className="text-muted-foreground">No import history yet</p>
</div>
)}
</CardContent>
</Card>
);
}
export default async function ImportPage() {
return (
<div>
<div className="mb-8">
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
Import Invoices
</h1>
<p className="mt-1 text-lg text-gray-600">
Upload CSV files to create invoices in batch.
</p>
</div>
<div className="space-y-8">
<PageHeader
title="Import Invoices"
description="Upload CSV files to create invoices in batch"
variant="gradient"
>
<Link href="/dashboard/invoices">
<Button variant="outline" size="lg">
<ArrowLeft className="mr-2 h-5 w-5" />
Back to Invoices
</Button>
</Link>
</PageHeader>
<HydrateClient>
<CSVImportPage />
<Suspense
fallback={
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i} className="border-0 shadow-md">
<CardContent className="p-6">
<div className="animate-pulse">
<div className="bg-muted mb-2 h-4 w-1/2 rounded"></div>
<div className="bg-muted mb-2 h-8 w-3/4 rounded"></div>
<div className="bg-muted h-3 w-1/3 rounded"></div>
</div>
</CardContent>
</Card>
))}
</div>
}
>
<ImportStats />
</Suspense>
<FileUploadArea />
<FormatInstructions />
<ImportantNotes />
<ImportHistory />
</HydrateClient>
</div>
);
+734 -14
View File
@@ -1,20 +1,740 @@
import { HydrateClient } from "~/trpc/server";
import { InvoiceForm } from "~/components/invoice-form";
"use client";
import React, { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { NumberInput } from "~/components/ui/number-input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { PageHeader } from "~/components/page-header";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { DatePicker } from "~/components/ui/date-picker";
import { FloatingActionBar } from "~/components/ui/floating-action-bar";
import { toast } from "sonner";
import {
ArrowLeft,
Save,
Plus,
Trash2,
FileText,
Building,
User,
Loader2,
Send,
DollarSign,
Hash,
Edit3,
} from "lucide-react";
interface InvoiceItem {
tempId: string;
date: Date;
description: string;
hours: number;
rate: number;
amount: number;
}
interface InvoiceFormData {
invoiceNumber: string;
businessId: string | undefined;
clientId: string;
issueDate: Date;
dueDate: Date;
notes: string;
taxRate: number;
items: InvoiceItem[];
}
function InvoiceItemCard({
item,
index,
onUpdate,
onDelete,
_isLast,
}: {
item: InvoiceItem;
index: number;
onUpdate: (
index: number,
field: keyof InvoiceItem,
value: string | number | Date,
) => void;
onDelete: (index: number) => void;
_isLast: boolean;
}) {
const handleFieldChange = (
field: keyof InvoiceItem,
value: string | number | Date,
) => {
onUpdate(index, field, value);
};
export default async function NewInvoicePage() {
return (
<div>
<div className="mb-8">
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
Create Invoice
</h1>
<p className="mt-1 text-lg text-gray-600">
Fill out the details below to create a new invoice.
</p>
<Card className="border-border/50 border p-3 shadow-sm">
<div className="space-y-3">
{/* Header with item number and delete */}
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs font-medium">
Item {index + 1}
</span>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Item</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this line item? This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(index)}
className="bg-red-600 hover:bg-red-700"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{/* Description */}
<Textarea
value={item.description}
onChange={(e) => handleFieldChange("description", e.target.value)}
placeholder="Description of work..."
className="min-h-[48px] resize-none text-sm"
rows={1}
/>
{/* Date, Hours, Rate, Amount in compact grid */}
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
<div className="space-y-1">
<Label className="text-xs font-medium">Date</Label>
<DatePicker
date={item.date}
onDateChange={(date) =>
handleFieldChange("date", date ?? new Date())
}
className="[&>button]:h-8 [&>button]:text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium">Hours</Label>
<NumberInput
value={item.hours}
onChange={(value) => handleFieldChange("hours", value)}
min={0}
step={0.25}
placeholder="0"
className="text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium">Rate</Label>
<NumberInput
value={item.rate}
onChange={(value) => handleFieldChange("rate", value)}
min={0}
step={0.25}
placeholder="0.00"
prefix="$"
className="text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium">Amount</Label>
<div className="bg-muted/30 flex h-8 items-center rounded-md border px-2">
<span className="font-mono text-xs font-medium text-emerald-600">
${(item.hours * item.rate).toFixed(2)}
</span>
</div>
</div>
</div>
</div>
<HydrateClient>
<InvoiceForm />
</HydrateClient>
</Card>
);
}
export default function NewInvoicePage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
// Initialize form data with defaults
const today = new Date();
const thirtyDaysFromNow = new Date(today);
thirtyDaysFromNow.setDate(today.getDate() + 30);
// Auto-generate invoice number
const generateInvoiceNumber = () => {
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const timestamp = Date.now().toString().slice(-4);
return `INV-${year}${month}-${timestamp}`;
};
const [formData, setFormData] = useState<InvoiceFormData>({
invoiceNumber: generateInvoiceNumber(),
businessId: undefined,
clientId: "",
issueDate: today,
dueDate: thirtyDaysFromNow,
notes: "",
taxRate: 0,
items: [
{
tempId: `item-${Date.now()}`,
date: today,
description: "",
hours: 0,
rate: 0,
amount: 0,
},
],
});
// Floating action bar ref
const footerRef = useRef<HTMLDivElement>(null);
// Queries
const { data: clients, isLoading: clientsLoading } =
api.clients.getAll.useQuery();
const { data: businesses, isLoading: businessesLoading } =
api.businesses.getAll.useQuery();
// Set default business when data loads
useEffect(() => {
if (businesses && !formData.businessId) {
const defaultBusiness = businesses.find((b) => b.isDefault);
if (defaultBusiness) {
setFormData((prev) => ({ ...prev, businessId: defaultBusiness.id }));
}
}
}, [businesses, formData.businessId]);
// Mutations
const createInvoice = api.invoices.create.useMutation({
onSuccess: (invoice) => {
toast.success("Invoice created successfully");
router.push(`/dashboard/invoices/${invoice.id}`);
},
onError: (error) => {
toast.error(error.message || "Failed to create invoice");
},
});
const handleItemUpdate = (
index: number,
field: keyof InvoiceItem,
value: string | number | Date,
) => {
const updatedItems = [...formData.items];
const currentItem = updatedItems[index];
if (currentItem) {
updatedItems[index] = { ...currentItem, [field]: value };
// Recalculate amount for hours or rate changes
if (field === "hours" || field === "rate") {
const updatedItem = updatedItems[index];
if (!updatedItem) return;
updatedItem.amount = updatedItem.hours * updatedItem.rate;
}
}
setFormData({ ...formData, items: updatedItems });
};
const handleItemDelete = (index: number) => {
if (formData.items.length === 1) {
toast.error("At least one line item is required");
return;
}
const updatedItems = formData.items.filter((_, i) => i !== index);
setFormData({ ...formData, items: updatedItems });
};
const handleAddItem = () => {
const newItem: InvoiceItem = {
tempId: `item-${Date.now()}`,
date: new Date(),
description: "",
hours: 0,
rate: 0,
amount: 0,
};
setFormData({
...formData,
items: [...formData.items, newItem],
});
};
const handleSaveDraft = async () => {
await handleSave("draft");
};
const handleCreateInvoice = async () => {
await handleSave("sent");
};
const handleSave = async (status: "draft" | "sent") => {
// Validation
if (!formData.clientId) {
toast.error("Please select a client");
return;
}
if (formData.items.length === 0) {
toast.error("At least one line item is required");
return;
}
// Check if all items have required fields
const invalidItems = formData.items.some(
(item) => !item.description.trim() || item.hours <= 0 || item.rate <= 0,
);
if (invalidItems) {
toast.error("All line items must have description, hours, and rate");
return;
}
setIsLoading(true);
try {
await createInvoice.mutateAsync({
...formData,
businessId: formData.businessId ?? undefined,
status,
});
} finally {
setIsLoading(false);
}
};
const calculateSubtotal = () => {
return formData.items.reduce((sum, item) => sum + item.amount, 0);
};
const calculateTax = () => {
return (calculateSubtotal() * formData.taxRate) / 100;
};
const calculateTotal = () => {
return calculateSubtotal() + calculateTax();
};
const isFormValid = () => {
return (
formData.clientId &&
formData.items.length > 0 &&
formData.items.every(
(item) => item.description.trim() && item.hours > 0 && item.rate > 0,
)
);
};
if (clientsLoading || businessesLoading) {
return (
<div className="space-y-6">
<PageHeader
title="Create Invoice"
description="Loading form data..."
variant="gradient"
/>
<Card className="shadow-xl">
<CardContent className="flex items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin text-emerald-600" />
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Create Invoice"
description="Fill out the details below to create a new invoice"
variant="gradient"
>
<Link href="/dashboard/invoices">
<Button variant="outline" size="sm" className="w-full sm:w-auto">
<ArrowLeft className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">Back to Invoices</span>
<span className="sm:hidden">Back</span>
</Button>
</Link>
</PageHeader>
<div className="space-y-6">
{/* Invoice Header */}
<Card className="shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-emerald-600" />
Invoice Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label className="text-sm font-medium">Invoice Number</Label>
<div className="bg-muted/30 flex h-10 items-center rounded-md border px-3">
<Hash className="text-muted-foreground mr-2 h-4 w-4" />
<span className="font-mono text-sm font-medium">
{formData.invoiceNumber}
</span>
</div>
</div>
<DatePicker
date={formData.issueDate}
onDateChange={(date) =>
setFormData({
...formData,
issueDate: date ?? new Date(),
})
}
label="Issue Date"
required
/>
<DatePicker
date={formData.dueDate}
onDateChange={(date) =>
setFormData({
...formData,
dueDate: date ?? new Date(),
})
}
label="Due Date"
required
/>
</div>
</CardContent>
</Card>
{/* Business & Client */}
<Card className="shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building className="h-5 w-5 text-emerald-600" />
Business & Client
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-sm font-medium">From Business</Label>
<div className="relative">
<Building className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Select
value={formData.businessId ?? ""}
onValueChange={(value) =>
setFormData({
...formData,
businessId: value || undefined,
})
}
>
<SelectTrigger className="pl-9">
<SelectValue placeholder="Select business..." />
</SelectTrigger>
<SelectContent>
{businesses?.map((business) => (
<SelectItem key={business.id} value={business.id}>
<div className="flex items-center gap-2">
<span>{business.name}</span>
{business.isDefault && (
<Badge variant="secondary" className="text-xs">
Default
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{(!businesses || businesses.length === 0) && (
<p className="text-sm text-red-600">
No businesses found.{" "}
<Link
href="/dashboard/businesses/new"
className="underline hover:text-red-700"
>
Create one first
</Link>
</p>
)}
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Client *</Label>
<div className="relative">
<User className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Select
value={formData.clientId}
onValueChange={(value) =>
setFormData({ ...formData, clientId: value })
}
>
<SelectTrigger className="pl-9">
<SelectValue placeholder="Select client..." />
</SelectTrigger>
<SelectContent>
{clients?.map((client) => (
<SelectItem key={client.id} value={client.id}>
<div>
<div className="font-medium">{client.name}</div>
<div className="text-muted-foreground text-sm">
{client.email}
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{(!clients || clients.length === 0) && (
<p className="text-sm text-red-600">
No clients found.{" "}
<Link
href="/dashboard/clients/new"
className="underline hover:text-red-700"
>
Create one first
</Link>
</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Line Items */}
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Edit3 className="h-5 w-5 text-emerald-600" />
Line Items ({formData.items.length})
</CardTitle>
<Button
onClick={handleAddItem}
type="button"
variant="outline"
size="sm"
className="shrink-0"
>
<Plus className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">Add Item</span>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{formData.items.map((item, index) => (
<InvoiceItemCard
key={item.tempId}
item={item}
index={index}
onUpdate={handleItemUpdate}
onDelete={handleItemDelete}
_isLast={index === formData.items.length - 1}
/>
))}
</CardContent>
</Card>
{/* Tax & Totals */}
<Card className="shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="h-5 w-5 text-emerald-600" />
Tax & Totals
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Tax Rate (%)</Label>
<NumberInput
value={formData.taxRate}
onChange={(value) =>
setFormData({
...formData,
taxRate: value,
})
}
min={0}
max={100}
step={0.01}
placeholder="0.00"
suffix="%"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Notes</Label>
<Textarea
value={formData.notes}
onChange={(e) =>
setFormData({ ...formData, notes: e.target.value })
}
placeholder="Payment terms, additional notes..."
rows={4}
className="resize-none"
/>
</div>
</div>
<div className="space-y-4">
<div className="bg-muted/20 rounded-lg border p-4">
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Subtotal:</span>
<span className="font-mono font-medium">
${calculateSubtotal().toFixed(2)}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
Tax ({formData.taxRate}%):
</span>
<span className="font-mono font-medium">
${calculateTax().toFixed(2)}
</span>
</div>
<Separator />
<div className="flex justify-between text-lg font-bold">
<span>Total:</span>
<span className="font-mono text-emerald-600">
${calculateTotal().toFixed(2)}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div
ref={footerRef}
className="flex flex-col gap-3 border-t pt-6 sm:flex-row sm:justify-between"
>
<Link href="/dashboard/invoices">
<Button variant="outline" className="w-full sm:w-auto">
<ArrowLeft className="mr-2 h-4 w-4" />
Cancel
</Button>
</Link>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Button
onClick={handleSaveDraft}
disabled={isLoading || !isFormValid()}
variant="outline"
className="w-full sm:w-auto"
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Draft
</Button>
<Button
onClick={handleCreateInvoice}
disabled={isLoading || !isFormValid()}
className="w-full bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700 sm:w-auto"
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Create Invoice
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
<FloatingActionBar triggerRef={footerRef} title="Creating a new invoice">
<Link href="/dashboard/invoices">
<Button
variant="outline"
disabled={isLoading}
className="border-border/40 hover:bg-accent/50"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Cancel
</Button>
</Link>
<Button
onClick={handleSaveDraft}
disabled={isLoading || !isFormValid()}
variant="outline"
className="border-border/40 hover:bg-accent/50"
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Draft
</Button>
<Button
onClick={handleCreateInvoice}
disabled={isLoading || !isFormValid()}
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Create Invoice
</Button>
</FloatingActionBar>
</div>
);
}
+38 -36
View File
@@ -1,47 +1,49 @@
import Link from "next/link";
import { Suspense } from "react";
import { api, HydrateClient } from "~/trpc/server";
import { Button } from "~/components/ui/button";
import { PageHeader } from "~/components/page-header";
import { Plus, Upload } from "lucide-react";
import { InvoicesTable } from "./_components/invoices-table";
import { InvoicesDataTable } from "./_components/invoices-data-table";
import { DataTableSkeleton } from "~/components/ui/data-table";
// Invoices Table Component
async function InvoicesTable() {
const invoices = await api.invoices.getAll();
return <InvoicesDataTable invoices={invoices} />;
}
export default async function InvoicesPage() {
return (
<div>
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-3xl font-bold text-transparent">
Invoices
</h1>
<p className="mt-1 text-lg text-gray-600">
Manage your invoices and payments.
</p>
</div>
<div className="flex gap-3">
<Button
asChild
variant="outline"
size="lg"
className="border-gray-200 bg-white/80 font-medium text-gray-700 shadow-lg hover:bg-gray-50 hover:shadow-xl"
>
<Link href="/dashboard/invoices/import">
<Upload className="mr-2 h-5 w-5" /> Import CSV
</Link>
</Button>
<Button
asChild
size="lg"
className="bg-gradient-to-r from-emerald-600 to-teal-600 font-medium text-white shadow-lg hover:from-emerald-700 hover:to-teal-700 hover:shadow-xl"
>
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-5 w-5" /> Add Invoice
</Link>
</Button>
</div>
</div>
<>
<PageHeader
title="Invoices"
description="Manage your invoices and track payments"
variant="gradient"
>
<Button asChild variant="outline" className="shadow-sm">
<Link href="/dashboard/invoices/import">
<Upload className="mr-2 h-5 w-5" />
<span>Import CSV</span>
</Link>
</Button>
<Button
asChild
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
>
<Link href="/dashboard/invoices/new">
<Plus className="mr-2 h-5 w-5" />
<span>Create Invoice</span>
</Link>
</Button>
</PageHeader>
<HydrateClient>
<InvoicesTable />
<Suspense fallback={<DataTableSkeleton columns={7} rows={5} />}>
<InvoicesTable />
</Suspense>
</HydrateClient>
</div>
</>
);
}