Update Next.js to version 15.4.2 and refactor invoice pages

- Upgrade Next.js and related packages for improved performance and security
- Refactor invoice-related pages to streamline navigation and enhance user experience
- Consolidate invoice editing and viewing functionality into a single page
- Remove deprecated edit page and implement a new view page for invoices
- Update links and routing for consistency across the dashboard
This commit is contained in:
2025-07-18 20:18:43 -04:00
parent 505d47918e
commit 3ac6e4d5b8
14 changed files with 727 additions and 1589 deletions
-719
View File
@@ -1,719 +0,0 @@
"use client";
import React, { useState, useEffect } 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/layout/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/layout/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);
};
return (
<Card className="card-secondary">
<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="text-icon-red hover:text-error h-6 w-6 p-0"
>
<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="btn-danger"
>
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 md: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())
}
size="sm"
className="w-full"
/>
</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"
/>
</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="$"
/>
</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="amount-primary">
${(item.hours * item.rate).toFixed(2)}
</span>
</div>
</div>
</div>
</div>
</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,
},
],
});
// 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="card-primary">
<CardContent className="flex items-center justify-center p-8">
<Loader2 className="text-icon-emerald h-8 w-8 animate-spin" />
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6 pb-32">
<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 md:w-auto">
<ArrowLeft className="mr-2 h-4 w-4" />
<span className="hidden md:inline">Back to Invoices</span>
<span className="md:hidden">Back</span>
</Button>
</Link>
</PageHeader>
<div className="space-y-6">
{/* Invoice Header */}
<Card className="card-primary">
<CardHeader>
<CardTitle className="card-title-secondary">
<FileText className="text-icon-emerald h-5 w-5" />
Invoice Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md: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>
<div className="space-y-2">
<Label className="text-sm font-medium">Issue Date *</Label>
<DatePicker
date={formData.issueDate}
onDateChange={(date) =>
setFormData({
...formData,
issueDate: date ?? new Date(),
})
}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Due Date *</Label>
<DatePicker
date={formData.dueDate}
onDateChange={(date) =>
setFormData({
...formData,
dueDate: date ?? new Date(),
})
}
/>
</div>
</div>
</CardContent>
</Card>
{/* Business & Client */}
<Card className="card-primary">
<CardHeader>
<CardTitle className="card-title-secondary">
<Building className="text-icon-emerald h-5 w-5" />
Business & Client
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md: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 className="badge-secondary text-xs">
Default
</Badge>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{(!businesses || businesses.length === 0) && (
<p className="text-icon-red text-sm">
No businesses found.{" "}
<Link
href="/dashboard/businesses/new"
className="link-secondary"
>
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="card-primary">
<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 md:mr-2" />
<span className="hidden md: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="card-primary">
<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="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2 md:col-span-1">
<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="%"
width="full"
/>
</div>
</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>
</CardContent>
</Card>
</div>
<FloatingActionBar
leftContent={
<div className="flex items-center space-x-3">
<div className="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">
Creating a new invoice
</p>
<p className="text-sm text-gray-600 dark:text-gray-300">
Complete the form to create your invoice
</p>
</div>
</div>
}
>
<Link href="/dashboard/invoices">
<Button
variant="outline"
disabled={isLoading}
className="border-border/40 hover:bg-accent/50"
size="sm"
>
<ArrowLeft className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline">Cancel</span>
</Button>
</Link>
<Button
onClick={handleSaveDraft}
disabled={isLoading || !isFormValid()}
variant="outline"
className="border-border/40 hover:bg-accent/50"
size="sm"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin md:mr-2" />
) : (
<Save className="h-4 w-4 md:mr-2" />
)}
<span className="hidden md:inline">Save Draft</span>
</Button>
<Button
onClick={handleCreateInvoice}
disabled={isLoading || !isFormValid()}
className="btn-brand-primary shadow-md"
size="sm"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin md:mr-2" />
) : (
<Send className="h-4 w-4 md:mr-2" />
)}
<span className="hidden md:inline">Create Invoice</span>
</Button>
</FloatingActionBar>
</div>
);
}